7 Commits

17 changed files with 2274 additions and 78 deletions

View File

@@ -4,6 +4,57 @@ All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project follows Semantic Versioning. The format is based on Keep a Changelog, and this project follows Semantic Versioning.
## [0.3.0] - 2026-04-06
### Added
- Two-line compact header with live session state: version, history count, filter state, hidden/files toggles, and development summary
- Persistent history storage between sessions with configurable state path and bounded history size
- History list rendering in most-recent-first order
- History filter mode (`/` inside history) with dedicated query and navigation behavior
- Development info panel (`i`) with git context, detected stack, and suggested commands
- Configurable open-with menu order via `config.toml`:
- `open_with.files`
- `open_with.directories`
- Automatic creation of opener config template when `config.toml` is missing
- New `opencode` opener support for directory actions
### Improved
- Filter label highlighting now works consistently in both tree and history filter modes
- UI color behavior is now explicitly normalized to avoid terminal color bleeding
- History selection and filtering logic aligned with reversed (most-recent-first) history rendering
## [0.2.6] - 2026-04-04
### Added
- Incremental filter workflow (`/`) with contextual Enter behavior:
- Directories navigate in-place
- Files open the app menu
- File visibility toggle (`a`) to switch between directories-only and mixed view
- Open-with app menu (`o`) with arrow-key selection (`↑`/`↓`, `Enter`, `Esc`)
- New opener support for `nano`, `less`, and `bat` for file actions
- Blocking terminal app execution with UI state restore after exit
### Improved
- Open-with options are now context-aware (GUI/headless, file/directory) and only shown when executables are available
- Better handling of paths containing spaces when launching openers
- Extended README with full controls reference and platform compatibility guidance
## [0.2.5] - 2026-04-03
### Improved
- Add explicit repository link in README for PyPI users to access the full demo and documentation
## [0.2.4] - 2026-04-03
### Fixed
- Use absolute demo GIF URL in README so image renders correctly on PyPI
## [0.2.3] - 2026-04-02
### Added
- Add project demo GIF at `docs/demo.gif`
- Show GIF demo in README
## [0.2.2] - 2026-04-02 ## [0.2.2] - 2026-04-02
### Fixed ### Fixed

108
README.md
View File

@@ -5,7 +5,9 @@
`cd-browser` is a fast keyboard-driven directory navigator for the terminal. `cd-browser` is a fast keyboard-driven directory navigator for the terminal.
It lets you explore directory trees visually and jump to any folder instantly. It lets you explore directory trees visually and jump to any folder instantly.
![cd-browser demo](docs/demo.gif) ![cd-browser demo](https://gitea.sakydogalo.es/saky/cd-browser/raw/branch/main/docs/demo.gif)
Repository (full demo + docs): https://gitea.sakydogalo.es/saky/cd-browser
## Why cd-browser? ## Why cd-browser?
@@ -19,14 +21,97 @@ Working in the terminal often means:
## Features ## Features
- 🚀 Fast visual navigation of directory trees - ⌨️ Fully keyboard-driven workflow (`↑` / `↓` / `←` / `→`)
- ⌨️ Fully keyboard-driven workflow - 🌲 Expand and collapse directories (`→` to expand/enter, `←` to collapse/back)
- 🌲 Expand and collapse directories - 📜 Persistent history between sessions (`h` history mode, `b` back, `f` forward)
- 📜 Navigation history inside the session - 🔎 Incremental filter mode (`/`, type to filter, `Backspace`, `Esc`)
- 🧭 History filter mode (`/` inside history) with most-recent-first ordering
- 📄 Toggle file visibility (`a`) to switch between directories-only and mixed view
- ⚡ Fast jumps in large lists (`PgUp`/`PgDn`, `Home`/`End`, `g`/`G` in normal mode)
- 🚀 Contextual `open with` menu (`o`) with app detection based on your environment
- 🔁 Blocking terminal editors restore the UI state when they exit (`nvim`, `nano`, `less`, `bat`)
- 🧩 Configurable app menu via `config.toml` with automatic template creation
- 🛠 Development snapshot in header + detailed info panel (`i`)
- 👀 Toggle hidden entries with `.`
- 🖥 Native terminal interface - 🖥 Native terminal interface
- 🔁 Works with Bash, Zsh and other shells - 🔁 Works with Bash, Zsh and other shells
- ⚡ Returns the selected path to the shell - ⚡ Returns the selected path to the shell (`Enter` to confirm, `Esc` to cancel)
- 🔎 Press `.` to toggle hidden directories
## Controls
Normal mode:
- `↑` / `↓`: Move selection
- `→`: Expand directory, or enter when already expanded
- `←`: Collapse directory, or go to parent
- `Enter`: Confirm selected path and exit
- `Esc`: Cancel and return the original path
- `h`: Toggle history mode
- `b` / `f`: Back / forward in navigation history
- `.`: Toggle hidden entries
- `a`: Toggle file visibility
- `/`: Enter filter mode
- `o`: Open selected entry with an app
- `i`: Toggle development info panel
- `PgUp` / `PgDn`: Page jump
- `Home` / `End`: Jump to first/last entry
- `g` / `G`: Jump to first/last entry
Filter mode:
- Type text: Filter visible entries in real time (case-insensitive)
- `↑` / `↓`: Move inside filtered results
- `Enter`: Apply filter and perform contextual action:
- Directory: navigate into it without exiting `cd_`
- File: open app selection menu
- `Esc`: Exit filter mode and clear query
- `Backspace`: Remove last filter character
History mode:
- `↑` / `↓`: Move through history entries (most recent at top)
- `Enter`: Jump to selected history path
- `Esc` or `h`: Exit history mode
- `PgUp` / `PgDn`, `Home` / `End`: Fast history navigation
- `/`: Enter history filter mode
History filter mode:
- Type text: Filter history entries in real time (case-insensitive)
- `↑` / `↓`: Move inside filtered history results
- `Enter`: Jump to selected history path
- `Esc`: Exit history filter mode and clear query
- `Backspace`: Remove last filter character
Open with menu (`o`):
- `↑` / `↓`: Move between app options
- `Enter`: Launch selected app
- `Esc`: Cancel
The menu only shows apps available in your system (`PATH`) and context:
- GUI options (when GUI is available): `code`, system `open`/`xdg-open`
- Terminal options: `antigravity`, `opencode`, `nvim`, `nano`, `less`, `bat` (as applicable)
## Opener Configuration
`cd-browser` reads opener settings from:
- macOS/Linux: `~/.config/cd-browser/config.toml`
- Windows: `%APPDATA%/cd-browser/config.toml`
If the file does not exist, `cd-browser` creates it automatically with defaults.
Example:
```toml
[open_with]
files = ["code", "open", "antigravity", "nvim", "nano", "less", "bat"]
directories = ["code", "opencode", "antigravity", "nvim"]
```
You can reorder or remove entries to customize app priority.
## Quick Demo ## Quick Demo
@@ -97,7 +182,14 @@ Then use:
cd_ cd_
``` ```
- Dentro de `cd_browser`, presiona `.` para alternar la visualización de carpetas ocultas. - Inside `cd_browser`, press `.` to toggle hidden entries.
## Platform Compatibility
Practical conclusion:
- Best current support: **macOS and Linux**
- **Windows is partially supported**, but still needs real-world validation in Windows terminal environments before being considered fully supported
## Uninstall ## Uninstall

BIN
docs/demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

34
docs/logo-galicia-ansi-demo.zsh Executable file
View File

@@ -0,0 +1,34 @@
#!/bin/zsh
set +H
blue=$'\033[38;2;0;114;206m'
white=$'\033[38;2;245;245;245m'
reset=$'\033[0m'
print_line() {
local r="$1"
local line="$2"
local out=""
local c ch center band
center=$((6 + r*12))
band=6
for ((c=1; c<=${#line}; c++)); do
ch="${line[c]}"
if [[ "$ch" == " " ]]; then
out+=" "
elif (( c-1 >= center-band && c-1 <= center+band )); then
out+="${blue}${ch}${reset}"
else
out+="${white}${ch}${reset}"
fi
done
print -r -- "$out"
}
print_line 0 " █ ███ "
print_line 1 " █ █ █ ██ ██ "
print_line 2 " ███ ███ ███ █/█ ██ █ █ █_ █__█ █/█"
print_line 3 "█ █ █ ███ █ █ █ █ █ █ █ █ █ █ █"
print_line 4 " ███ ███ ███ █ ██ █ █ ██ ██ █"

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "cd-browser" name = "cd-browser"
version = "0.2.2" version = "0.3.0"
description = "A fast keyboard-driven directory navigator for the terminal." description = "A fast keyboard-driven directory navigator for the terminal."
readme = "README.md" readme = "README.md"
requires-python = ">=3.8" requires-python = ">=3.8"

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from app.history import SessionHistory
from app.navigator import Navigator from app.navigator import Navigator
from app.ui import TerminalUI from app.ui import TerminalUI
@@ -10,6 +11,6 @@ def run_cli(start_path: Path | None = None, ui: TerminalUI | None = None) -> Pat
"""Run the interactive directory browser and return the selected path.""" """Run the interactive directory browser and return the selected path."""
initial_path = (start_path or Path.cwd()).expanduser().resolve() initial_path = (start_path or Path.cwd()).expanduser().resolve()
navigator = Navigator(initial_path) navigator = Navigator(initial_path, history=SessionHistory.persistent())
terminal_ui = ui or TerminalUI() terminal_ui = ui or TerminalUI()
return terminal_ui.run(navigator) return terminal_ui.run(navigator)

168
src/app/devinfo.py Normal file
View File

@@ -0,0 +1,168 @@
from __future__ import annotations
import subprocess
import time
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True, slots=True)
class DevSnapshot:
"""Development context for a path."""
repo_root: Path | None
git_branch: str | None
git_dirty: bool
last_commit: str | None
stack: tuple[str, ...]
commands: tuple[str, ...]
@property
def header_summary(self) -> str:
git_part = "git:-"
if self.git_branch:
dirty_marker = "*" if self.git_dirty else ""
git_part = f"git:{self.git_branch}{dirty_marker}"
stack_part = ",".join(self.stack[:2]) if self.stack else "-"
return f"{git_part} stack:{stack_part}"
def detail_lines(self, current_path: Path) -> list[str]:
lines = [f"Current path: {current_path}"]
if self.repo_root is None:
lines.append("Git: not a repository")
else:
dirty_text = "dirty" if self.git_dirty else "clean"
lines.append(f"Git root: {self.repo_root}")
lines.append(f"Git branch: {self.git_branch or '-'} ({dirty_text})")
if self.last_commit:
lines.append(f"Last commit: {self.last_commit}")
stack_text = ", ".join(self.stack) if self.stack else "-"
lines.append(f"Detected stack: {stack_text}")
if self.commands:
lines.append("Suggested commands:")
for command in self.commands:
lines.append(f" - {command}")
return lines
class DevInfoCache:
"""Simple TTL cache for development snapshots."""
def __init__(self, ttl_seconds: float = 3.0) -> None:
self._ttl_seconds = ttl_seconds
self._cached_path: Path | None = None
self._cached_at: float = 0.0
self._cached_snapshot: DevSnapshot | None = None
def get(self, path: Path) -> DevSnapshot:
resolved_path = path.expanduser().resolve()
now = time.monotonic()
if (
self._cached_snapshot is not None
and self._cached_path == resolved_path
and (now - self._cached_at) <= self._ttl_seconds
):
return self._cached_snapshot
snapshot = collect_dev_snapshot(resolved_path)
self._cached_path = resolved_path
self._cached_snapshot = snapshot
self._cached_at = now
return snapshot
def collect_dev_snapshot(path: Path) -> DevSnapshot:
"""Collect git and stack metadata for a path."""
repo_root = _git_repo_root(path)
git_branch: str | None = None
git_dirty = False
last_commit: str | None = None
if repo_root is not None:
git_branch = _run_command(
["git", "-C", str(repo_root), "rev-parse", "--abbrev-ref", "HEAD"]
)
status_output = _run_command(
["git", "-C", str(repo_root), "status", "--porcelain"]
)
git_dirty = bool(status_output)
last_commit = _run_command(
["git", "-C", str(repo_root), "log", "-1", "--pretty=format:%h %s"]
)
stack = _detect_stack(path)
return DevSnapshot(
repo_root=repo_root,
git_branch=git_branch,
git_dirty=git_dirty,
last_commit=last_commit,
stack=stack,
commands=_suggested_commands(stack, repo_root is not None),
)
def _git_repo_root(path: Path) -> Path | None:
result = _run_command(["git", "-C", str(path), "rev-parse", "--show-toplevel"])
if not result:
return None
return Path(result).expanduser().resolve()
def _detect_stack(path: Path) -> tuple[str, ...]:
markers = (
("pyproject.toml", "python"),
("requirements.txt", "python"),
("package.json", "node"),
("go.mod", "go"),
("Cargo.toml", "rust"),
)
found: list[str] = []
for marker, stack_name in markers:
if (path / marker).exists() and stack_name not in found:
found.append(stack_name)
return tuple(found)
def _suggested_commands(stack: tuple[str, ...], has_git: bool) -> tuple[str, ...]:
commands: list[str] = []
if has_git:
commands.append("git status -sb")
if "python" in stack:
commands.extend(["pytest -q", "ruff check src tests"])
if "node" in stack:
commands.extend(["npm test", "npm run lint"])
if "go" in stack:
commands.append("go test ./...")
if "rust" in stack:
commands.append("cargo test")
return tuple(commands)
def _run_command(command: list[str]) -> str | None:
try:
result = subprocess.run(
command,
check=False,
capture_output=True,
text=True,
timeout=0.35,
)
except (OSError, subprocess.SubprocessError):
return None
if result.returncode != 0:
return None
output = result.stdout.strip()
if not output:
return None
return output

View File

@@ -13,11 +13,37 @@ class DirectoryEntry:
has_children: bool has_children: bool
@dataclass(frozen=True, slots=True)
class FileSystemEntry:
"""Filesystem entry metadata for tree rendering."""
name: str
path: Path
is_directory: bool
has_children: bool
def list_directories(path: Path, show_hidden: bool = False) -> list[DirectoryEntry]: def list_directories(path: Path, show_hidden: bool = False) -> list[DirectoryEntry]:
"""Return direct child directories sorted by name.""" """Return direct child directories sorted by name."""
entries = list_entries(path, show_hidden=show_hidden, include_files=False)
return [
DirectoryEntry(
name=entry.name,
path=entry.path,
has_children=entry.has_children,
)
for entry in entries
]
def list_entries(
path: Path, show_hidden: bool = False, include_files: bool = False
) -> list[FileSystemEntry]:
"""Return direct child entries sorted by type and name."""
directory_path = path.expanduser().resolve() directory_path = path.expanduser().resolve()
entries: list[DirectoryEntry] = [] entries: list[FileSystemEntry] = []
try: try:
children = list(directory_path.iterdir()) children = list(directory_path.iterdir())
@@ -30,20 +56,28 @@ def list_directories(path: Path, show_hidden: bool = False) -> list[DirectoryEnt
except OSError: except OSError:
continue continue
if not is_directory: if not is_directory and not include_files:
continue continue
if not show_hidden and child.name.startswith("."): if not show_hidden and child.name.startswith("."):
continue continue
entries.append( entries.append(
DirectoryEntry( FileSystemEntry(
name=child.name, name=child.name,
path=child, path=child,
has_children=has_subdirectories(child, show_hidden=show_hidden), is_directory=is_directory,
has_children=(
has_subdirectories(child, show_hidden=show_hidden)
if is_directory
else False
),
) )
) )
return sorted(entries, key=lambda entry: entry.name.casefold()) return sorted(
entries,
key=lambda entry: (not entry.is_directory, entry.name.casefold()),
)
def has_subdirectories(path: Path, show_hidden: bool = False) -> bool: def has_subdirectories(path: Path, show_hidden: bool = False) -> bool:

View File

@@ -1,15 +1,46 @@
from __future__ import annotations from __future__ import annotations
import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
def default_history_path() -> Path:
"""Return the default on-disk history location."""
override = os.environ.get("CD_BROWSER_HISTORY_FILE")
if override:
return Path(override).expanduser()
if os.name == "nt":
base = Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming")))
else:
base = Path(os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local/state")))
return base / "cd-browser" / "history.txt"
@dataclass(slots=True) @dataclass(slots=True)
class SessionHistory: class SessionHistory:
"""In-memory session history for visited directories.""" """In-memory session history for visited directories."""
_entries: list[Path] = field(default_factory=list) _entries: list[Path] = field(default_factory=list)
_index: int = -1 _index: int = -1
storage_path: Path | None = None
max_entries: int = 500
@classmethod
def persistent(
cls, storage_path: Path | None = None, max_entries: int = 500
) -> SessionHistory:
"""Create a history instance backed by an on-disk file."""
history = cls(
storage_path=storage_path or default_history_path(),
max_entries=max_entries,
)
history._load()
return history
def visit(self, path: Path) -> Path: def visit(self, path: Path) -> Path:
"""Record a directory visit and discard forward history when needed.""" """Record a directory visit and discard forward history when needed."""
@@ -23,7 +54,9 @@ class SessionHistory:
self._entries = self._entries[: self._index + 1] self._entries = self._entries[: self._index + 1]
self._entries.append(resolved_path) self._entries.append(resolved_path)
self._trim_to_limit()
self._index = len(self._entries) - 1 self._index = len(self._entries) - 1
self._persist()
return resolved_path return resolved_path
@property @property
@@ -66,3 +99,48 @@ class SessionHistory:
"""Return the full history as an immutable sequence.""" """Return the full history as an immutable sequence."""
return tuple(self._entries) return tuple(self._entries)
def _load(self) -> None:
if self.storage_path is None:
return
try:
lines = self.storage_path.read_text(encoding="utf-8").splitlines()
except FileNotFoundError:
return
except OSError:
return
loaded_entries: list[Path] = []
for line in lines:
raw = line.strip()
if not raw:
continue
resolved = Path(raw).expanduser().resolve()
if loaded_entries and loaded_entries[-1] == resolved:
continue
loaded_entries.append(resolved)
self._entries = loaded_entries
self._trim_to_limit()
self._index = len(self._entries) - 1
def _persist(self) -> None:
if self.storage_path is None:
return
try:
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
contents = "".join(f"{entry}\n" for entry in self._entries)
self.storage_path.write_text(contents, encoding="utf-8")
except OSError:
return
def _trim_to_limit(self) -> None:
limit = max(1, self.max_entries)
if len(self._entries) <= limit:
return
self._entries = self._entries[-limit:]

View File

@@ -3,13 +3,13 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from app.filesystem import list_directories from app.filesystem import list_entries
from app.history import SessionHistory from app.history import SessionHistory
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class VisibleEntry: class VisibleEntry:
"""A directory row visible in the navigator.""" """A row visible in the navigator tree."""
name: str name: str
path: Path path: Path
@@ -17,6 +17,7 @@ class VisibleEntry:
has_children: bool has_children: bool
is_expanded: bool is_expanded: bool
is_parent: bool = False is_parent: bool = False
is_file: bool = False
@dataclass(slots=True) @dataclass(slots=True)
@@ -29,6 +30,7 @@ class Navigator:
selected_index: int = field(init=False, default=0) selected_index: int = field(init=False, default=0)
_expanded_paths: set[Path] = field(init=False, default_factory=set) _expanded_paths: set[Path] = field(init=False, default_factory=set)
show_hidden: bool = False show_hidden: bool = False
show_files: bool = False
def __post_init__(self) -> None: def __post_init__(self) -> None:
self.current_path = self.start_path.expanduser().resolve() self.current_path = self.start_path.expanduser().resolve()
@@ -69,6 +71,9 @@ class Navigator:
def enter_selected_directory(self) -> Path: def enter_selected_directory(self) -> Path:
"""Enter the currently selected directory and record it in history.""" """Enter the currently selected directory and record it in history."""
if self.selected_entry.is_file:
return self.current_path
return self._change_directory(self.selected_entry.path) return self._change_directory(self.selected_entry.path)
def go_to_parent_directory(self) -> Path: def go_to_parent_directory(self) -> Path:
@@ -149,6 +154,15 @@ class Navigator:
"""Toggle showing/hiding hidden directory entries.""" """Toggle showing/hiding hidden directory entries."""
self.show_hidden = not self.show_hidden self.show_hidden = not self.show_hidden
self._reset_view_state()
def toggle_files(self) -> None:
"""Toggle showing/hiding file entries."""
self.show_files = not self.show_files
self._reset_view_state()
def _reset_view_state(self) -> None:
self.selected_index = 0 self.selected_index = 0
self._expanded_paths.clear() self._expanded_paths.clear()
@@ -162,20 +176,25 @@ class Navigator:
def _build_entries(self, path: Path, depth: int) -> list[VisibleEntry]: def _build_entries(self, path: Path, depth: int) -> list[VisibleEntry]:
entries: list[VisibleEntry] = [] entries: list[VisibleEntry] = []
for directory in list_directories(path, show_hidden=self.show_hidden): for item in list_entries(
is_expanded = directory.path in self._expanded_paths path, show_hidden=self.show_hidden, include_files=self.show_files
):
is_expanded = (
item.path in self._expanded_paths if item.is_directory else False
)
entries.append( entries.append(
VisibleEntry( VisibleEntry(
name=directory.name, name=item.name,
path=directory.path, path=item.path,
depth=depth, depth=depth,
has_children=directory.has_children, has_children=item.has_children,
is_expanded=is_expanded, is_expanded=is_expanded,
is_file=not item.is_directory,
) )
) )
if is_expanded: if is_expanded:
entries.extend(self._build_entries(directory.path, depth=depth + 1)) entries.extend(self._build_entries(item.path, depth=depth + 1))
return entries return entries

311
src/app/opener.py Normal file
View File

@@ -0,0 +1,311 @@
from __future__ import annotations
import os
import shlex
import shutil
import subprocess
import sys
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Literal
try:
import tomllib
except ModuleNotFoundError: # pragma: no cover - python < 3.11
tomllib = None # type: ignore[assignment]
LaunchMode = Literal["background", "blocking"]
DEFAULT_FILE_OPENERS = ("code", "open", "antigravity", "nvim", "nano", "less", "bat")
DEFAULT_DIRECTORY_OPENERS = ("code", "opencode", "antigravity", "nvim")
@dataclass(frozen=True, slots=True)
class OpenWithOption:
"""Candidate application command for opening a path."""
label: str
command_template: str
executable: str
launch_mode: LaunchMode
requires_gui: bool = False
supports_files: bool = True
supports_directories: bool = True
def available_openers(path: Path) -> list[OpenWithOption]:
"""Return available open-with options for a file or directory path."""
is_directory = path.is_dir()
has_gui = _has_gui_session()
options: list[OpenWithOption] = []
catalog = _options_catalog()
configured_option_ids = _configured_option_ids(is_directory, catalog)
for option_id in configured_option_ids:
option = catalog[option_id]
if is_directory and not option.supports_directories:
continue
if not is_directory and not option.supports_files:
continue
if option.requires_gui and not has_gui:
continue
if not shutil.which(option.executable):
continue
options.append(option)
return options
def launch_background_option(path: Path, option: OpenWithOption) -> tuple[bool, str]:
"""Launch a non-blocking option and return status/message."""
command = _command_parts(option.command_template, path)
if not command:
return False, "Invalid open command"
try:
subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except OSError as error:
return False, f"Open failed: {error}"
return True, f"Opened with {option.label}"
def launch_blocking_option(path: Path, option: OpenWithOption) -> tuple[bool, str]:
"""Launch a blocking terminal option and return status/message."""
command = _command_parts(option.command_template, path)
if not command:
return False, "Invalid open command"
try:
result = subprocess.run(command, check=False)
except OSError as error:
return False, f"Open failed: {error}"
if result.returncode != 0:
return (
False,
f"Open failed: {option.label} exited with code {result.returncode}",
)
return True, f"Opened with {option.label}"
def is_blocking_option(option: OpenWithOption) -> bool:
"""Return whether the option should block the current terminal session."""
return option.launch_mode == "blocking"
def _options_catalog() -> dict[str, OpenWithOption]:
return {
"code": OpenWithOption(
label="VS Code",
command_template="code {path}",
executable="code",
launch_mode="background",
requires_gui=True,
),
"open": OpenWithOption(
label="Open (system)",
command_template=_system_open_template(),
executable=_system_open_executable(),
launch_mode="background",
requires_gui=True,
supports_directories=False,
),
"opencode": OpenWithOption(
label="OpenCode",
command_template="opencode {path}",
executable="opencode",
launch_mode="background",
supports_files=False,
),
"antigravity": OpenWithOption(
label="Antigravity",
command_template="antigravity {path}",
executable="antigravity",
launch_mode="background",
),
"nvim": OpenWithOption(
label="Neovim",
command_template="nvim {path}",
executable="nvim",
launch_mode="blocking",
),
"nano": OpenWithOption(
label="Nano",
command_template="nano {path}",
executable="nano",
launch_mode="blocking",
supports_directories=False,
),
"less": OpenWithOption(
label="Less",
command_template="less {path}",
executable="less",
launch_mode="blocking",
supports_directories=False,
),
"bat": OpenWithOption(
label="Bat",
command_template="bat {path}",
executable="bat",
launch_mode="blocking",
supports_directories=False,
),
}
def _configured_option_ids(
is_directory: bool, catalog: dict[str, OpenWithOption]
) -> list[str]:
config = _load_openers_config()
defaults = (
list(DEFAULT_DIRECTORY_OPENERS) if is_directory else list(DEFAULT_FILE_OPENERS)
)
configured = config["directories"] if is_directory else config["files"]
seen: set[str] = set()
option_ids: list[str] = []
for option_id in configured:
if option_id not in catalog or option_id in seen:
continue
seen.add(option_id)
option_ids.append(option_id)
if option_ids:
return option_ids
return [option_id for option_id in defaults if option_id in catalog]
def _openers_config_path() -> Path:
override = os.environ.get("CD_BROWSER_CONFIG_FILE")
if override:
return Path(override).expanduser()
if os.name == "nt":
base = Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming")))
else:
base = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")))
return base / "cd-browser" / "config.toml"
@lru_cache(maxsize=1)
def _load_openers_config() -> dict[str, list[str]]:
defaults = {
"files": list(DEFAULT_FILE_OPENERS),
"directories": list(DEFAULT_DIRECTORY_OPENERS),
}
config_path = _openers_config_path()
_ensure_default_config_file(config_path)
if tomllib is None or not config_path.exists():
return defaults
try:
raw_data = tomllib.loads(config_path.read_text(encoding="utf-8"))
except (OSError, tomllib.TOMLDecodeError):
return defaults
if not isinstance(raw_data, dict):
return defaults
open_with = raw_data.get("open_with")
if not isinstance(open_with, dict):
return defaults
files = open_with.get("files")
directories = open_with.get("directories")
return {
"files": _normalize_ids(files, defaults["files"]),
"directories": _normalize_ids(directories, defaults["directories"]),
}
def _normalize_ids(value: object, fallback: list[str]) -> list[str]:
if not isinstance(value, list):
return fallback
normalized = [
item.strip() for item in value if isinstance(item, str) and item.strip()
]
return normalized if normalized else fallback
def _ensure_default_config_file(path: Path) -> None:
if path.exists():
return
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(_default_config_toml(), encoding="utf-8")
except OSError:
return
def _default_config_toml() -> str:
file_ids = '", "'.join(DEFAULT_FILE_OPENERS)
directory_ids = '", "'.join(DEFAULT_DIRECTORY_OPENERS)
return (
"# cd-browser opener configuration\n"
"# Reorder or remove items to customize menu priority.\n"
"[open_with]\n"
f'files = ["{file_ids}"]\n'
f'directories = ["{directory_ids}"]\n'
)
def _reset_openers_config_cache() -> None:
_load_openers_config.cache_clear()
def _has_gui_session() -> bool:
if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY"):
return False
if sys.platform == "darwin":
return True
if sys.platform.startswith("linux"):
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
if sys.platform.startswith("win"):
return True
return False
def _system_open_template() -> str:
if sys.platform == "darwin":
return "open {path}"
if sys.platform.startswith("linux"):
return "xdg-open {path}"
if sys.platform.startswith("win"):
return 'cmd /c start "" {path}'
return "open {path}"
def _system_open_executable() -> str:
if sys.platform == "darwin":
return "open"
if sys.platform.startswith("linux"):
return "xdg-open"
if sys.platform.startswith("win"):
return "cmd"
return "open"
def _command_parts(command_template: str, path: Path) -> list[str]:
rendered = command_template.format(path=shlex.quote(str(path)))
return shlex.split(rendered)

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,13 @@ from pathlib import Path
import pytest import pytest
from app.filesystem import DirectoryEntry, has_subdirectories, list_directories from app.filesystem import (
DirectoryEntry,
FileSystemEntry,
has_subdirectories,
list_directories,
list_entries,
)
def test_has_subdirectories_detects_nested_directory(tmp_path: Path) -> None: def test_has_subdirectories_detects_nested_directory(tmp_path: Path) -> None:
@@ -46,6 +52,28 @@ def test_list_directories_raises_for_missing_directory(tmp_path: Path) -> None:
list_directories(missing_path) list_directories(missing_path)
def test_list_entries_returns_files_when_include_files_enabled(tmp_path: Path) -> None:
folder = tmp_path / "folder"
file_path = tmp_path / "notes.txt"
folder.mkdir()
file_path.write_text("content", encoding="utf-8")
assert list_entries(tmp_path, include_files=True) == [
FileSystemEntry(
name="folder",
path=folder,
is_directory=True,
has_children=False,
),
FileSystemEntry(
name="notes.txt",
path=file_path,
is_directory=False,
has_children=False,
),
]
def test_list_directories_skips_permission_errors_for_children(tmp_path: Path) -> None: def test_list_directories_skips_permission_errors_for_children(tmp_path: Path) -> None:
root = tmp_path / "root" root = tmp_path / "root"
root.mkdir() root.mkdir()

View File

@@ -2,7 +2,7 @@ from pathlib import Path
import pytest import pytest
from app.history import SessionHistory from app.history import SessionHistory, default_history_path
def test_visit_records_current_directory(tmp_path: Path) -> None: def test_visit_records_current_directory(tmp_path: Path) -> None:
@@ -82,3 +82,48 @@ def test_select_raises_for_invalid_index() -> None:
with pytest.raises(IndexError): with pytest.raises(IndexError):
history.select(0) history.select(0)
def test_persistent_history_loads_entries_from_disk(tmp_path: Path) -> None:
first = tmp_path / "first"
second = tmp_path / "second"
for directory in (first, second):
directory.mkdir()
history_file = tmp_path / "history.txt"
history_file.write_text(f"{first}\n{second}\n", encoding="utf-8")
history = SessionHistory.persistent(storage_path=history_file)
assert history.entries() == (first.resolve(), second.resolve())
assert history.current == second.resolve()
def test_persistent_history_visit_writes_to_disk_and_applies_limit(
tmp_path: Path,
) -> None:
first = tmp_path / "first"
second = tmp_path / "second"
third = tmp_path / "third"
for directory in (first, second, third):
directory.mkdir()
history_file = tmp_path / "state" / "history.txt"
history = SessionHistory.persistent(storage_path=history_file, max_entries=2)
history.visit(first)
history.visit(second)
history.visit(third)
assert history.entries() == (second.resolve(), third.resolve())
assert history_file.read_text(encoding="utf-8").splitlines() == [
str(second.resolve()),
str(third.resolve()),
]
def test_default_history_path_uses_override_env(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
override = tmp_path / "custom-history.txt"
monkeypatch.setenv("CD_BROWSER_HISTORY_FILE", str(override))
assert default_history_path() == override

View File

@@ -50,6 +50,24 @@ def test_toggle_hidden_directories(tmp_path: Path) -> None:
assert [entry.name for entry in navigator.visible_entries] == ["..", "visible"] assert [entry.name for entry in navigator.visible_entries] == ["..", "visible"]
def test_toggle_files_includes_files_in_visible_entries(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
(root / "folder").mkdir()
(root / "notes.txt").write_text("hello", encoding="utf-8")
navigator = Navigator(root)
assert [entry.name for entry in navigator.visible_entries] == ["..", "folder"]
navigator.toggle_files()
assert navigator.show_files is True
assert [entry.name for entry in navigator.visible_entries] == [
"..",
"folder",
"notes.txt",
]
def test_expand_selected_directory_reveals_nested_entries(tmp_path: Path) -> None: def test_expand_selected_directory_reveals_nested_entries(tmp_path: Path) -> None:
current = tmp_path / "workspace" current = tmp_path / "workspace"
current.mkdir() current.mkdir()

254
tests/test_opener.py Normal file
View File

@@ -0,0 +1,254 @@
from __future__ import annotations
from pathlib import Path
from subprocess import CompletedProcess
import pytest
from app.opener import (
OpenWithOption,
_reset_openers_config_cache,
available_openers,
is_blocking_option,
launch_background_option,
launch_blocking_option,
)
@pytest.fixture(autouse=True)
def reset_openers_config_cache() -> None:
_reset_openers_config_cache()
def test_available_openers_headless_file_excludes_gui(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
file_path = tmp_path / "notes.txt"
file_path.write_text("hello", encoding="utf-8")
monkeypatch.setenv("SSH_CONNECTION", "1")
available = {
"antigravity": "/usr/local/bin/antigravity",
"nvim": "/usr/local/bin/nvim",
"nano": "/usr/bin/nano",
"less": "/usr/bin/less",
"bat": "/usr/local/bin/bat",
"code": "/usr/local/bin/code",
"open": "/usr/bin/open",
}
monkeypatch.setattr("app.opener.shutil.which", lambda name: available.get(name))
labels = [option.label for option in available_openers(file_path)]
assert "VS Code" not in labels
assert "Open (system)" not in labels
assert labels == ["Antigravity", "Neovim", "Nano", "Less", "Bat"]
def test_available_openers_gui_directory_filters_file_only_tools(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
folder = tmp_path / "project"
folder.mkdir()
monkeypatch.delenv("SSH_CONNECTION", raising=False)
monkeypatch.setattr("app.opener.sys.platform", "darwin")
available = {
"antigravity": "/usr/local/bin/antigravity",
"nvim": "/usr/local/bin/nvim",
"nano": "/usr/bin/nano",
"less": "/usr/bin/less",
"bat": "/usr/local/bin/bat",
"code": "/usr/local/bin/code",
"open": "/usr/bin/open",
}
monkeypatch.setattr("app.opener.shutil.which", lambda name: available.get(name))
labels = [option.label for option in available_openers(folder)]
assert labels == ["VS Code", "Antigravity", "Neovim"]
def test_available_openers_respects_configured_order(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config_path = tmp_path / "config.toml"
config_path.write_text(
('[open_with]\nfiles = ["nvim", "code"]\ndirectories = ["opencode", "code"]\n'),
encoding="utf-8",
)
monkeypatch.setenv("CD_BROWSER_CONFIG_FILE", str(config_path))
monkeypatch.delenv("SSH_CONNECTION", raising=False)
monkeypatch.setattr("app.opener.sys.platform", "darwin")
available = {
"opencode": "/usr/local/bin/opencode",
"nvim": "/usr/local/bin/nvim",
"code": "/usr/local/bin/code",
}
monkeypatch.setattr("app.opener.shutil.which", lambda name: available.get(name))
folder = tmp_path / "project"
folder.mkdir()
file_path = tmp_path / "notes.txt"
file_path.write_text("hello", encoding="utf-8")
folder_labels = [option.label for option in available_openers(folder)]
file_labels = [option.label for option in available_openers(file_path)]
assert folder_labels == ["OpenCode", "VS Code"]
assert file_labels == ["Neovim", "VS Code"]
def test_available_openers_invalid_config_falls_back_to_defaults(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config_path = tmp_path / "config.toml"
config_path.write_text("[open_with\n", encoding="utf-8")
monkeypatch.setenv("CD_BROWSER_CONFIG_FILE", str(config_path))
monkeypatch.setenv("SSH_CONNECTION", "1")
available = {
"antigravity": "/usr/local/bin/antigravity",
"nvim": "/usr/local/bin/nvim",
"nano": "/usr/bin/nano",
"less": "/usr/bin/less",
"bat": "/usr/local/bin/bat",
}
monkeypatch.setattr("app.opener.shutil.which", lambda name: available.get(name))
file_path = tmp_path / "notes.txt"
file_path.write_text("hello", encoding="utf-8")
labels = [option.label for option in available_openers(file_path)]
assert labels == ["Antigravity", "Neovim", "Nano", "Less", "Bat"]
def test_available_openers_creates_default_config_when_missing(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config_path = tmp_path / "config.toml"
monkeypatch.setenv("CD_BROWSER_CONFIG_FILE", str(config_path))
monkeypatch.setenv("SSH_CONNECTION", "1")
monkeypatch.setattr(
"app.opener.shutil.which",
lambda name: (
"/usr/bin/true" if name in {"nvim", "nano", "less", "bat"} else None
),
)
file_path = tmp_path / "notes.txt"
file_path.write_text("hello", encoding="utf-8")
available_openers(file_path)
assert config_path.exists()
config_text = config_path.read_text(encoding="utf-8")
assert "[open_with]" in config_text
assert (
'files = ["code", "open", "antigravity", "nvim", "nano", "less", "bat"]'
in config_text
)
def test_available_openers_does_not_override_existing_config(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config_path = tmp_path / "config.toml"
original = '[open_with]\nfiles = ["nvim"]\ndirectories = ["opencode"]\n'
config_path.write_text(original, encoding="utf-8")
monkeypatch.setenv("CD_BROWSER_CONFIG_FILE", str(config_path))
monkeypatch.setenv("SSH_CONNECTION", "1")
monkeypatch.setattr(
"app.opener.shutil.which",
lambda name: "/usr/bin/true" if name in {"nvim", "opencode"} else None,
)
file_path = tmp_path / "notes.txt"
file_path.write_text("hello", encoding="utf-8")
available_openers(file_path)
assert config_path.read_text(encoding="utf-8") == original
def test_launch_background_option_returns_success(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
option = OpenWithOption(
label="VS Code",
command_template="code {path}",
executable="code",
launch_mode="background",
requires_gui=True,
)
monkeypatch.setattr("app.opener.subprocess.Popen", lambda *args, **kwargs: None)
success, message = launch_background_option(tmp_path, option)
assert success is True
assert "VS Code" in message
def test_launch_blocking_option_reports_non_zero_exit(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
option = OpenWithOption(
label="Neovim",
command_template="nvim {path}",
executable="nvim",
launch_mode="blocking",
)
monkeypatch.setattr(
"app.opener.subprocess.run",
lambda *args, **kwargs: CompletedProcess(args=[], returncode=2),
)
success, message = launch_blocking_option(tmp_path, option)
assert success is False
assert "code 2" in message
def test_is_blocking_option_uses_launch_mode() -> None:
blocking = OpenWithOption(
label="Neovim",
command_template="nvim {path}",
executable="nvim",
launch_mode="blocking",
)
background = OpenWithOption(
label="VS Code",
command_template="code {path}",
executable="code",
launch_mode="background",
)
assert is_blocking_option(blocking) is True
assert is_blocking_option(background) is False
def test_launch_blocking_option_handles_paths_with_spaces(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
target_path = tmp_path / "COMPETENCIAS A.LEDO"
option = OpenWithOption(
label="Neovim",
command_template="nvim {path}",
executable="nvim",
launch_mode="blocking",
)
captured: dict[str, list[str]] = {}
def fake_run(command: list[str], check: bool) -> CompletedProcess[object]:
captured["command"] = command
return CompletedProcess(args=[], returncode=0)
monkeypatch.setattr("app.opener.subprocess.run", fake_run)
success, _message = launch_blocking_option(target_path, option)
assert success is True
assert captured["command"] == ["nvim", str(target_path)]

View File

@@ -5,11 +5,13 @@ import pytest
from app.navigator import Navigator, VisibleEntry from app.navigator import Navigator, VisibleEntry
from app.ui import ( from app.ui import (
ESCAPE_KEY,
HistoryLine, HistoryLine,
RenderLine, RenderLine,
TerminalUI, TerminalUI,
build_history_lines, build_history_lines,
build_render_lines, build_render_lines,
build_status_line,
clamp_scroll_offset, clamp_scroll_offset,
format_entry, format_entry,
move_selection, move_selection,
@@ -52,6 +54,19 @@ def test_format_entry_supports_parent_and_tree_indicators(tmp_path: Path) -> Non
assert format_entry(expanded_child) == " build" assert format_entry(expanded_child) == " build"
def test_format_entry_marks_files_with_file_indicator(tmp_path: Path) -> None:
file_entry = VisibleEntry(
name="notes.txt",
path=tmp_path / "notes.txt",
depth=0,
has_children=False,
is_expanded=False,
is_file=True,
)
assert format_entry(file_entry) == "• notes.txt"
def test_build_render_lines_marks_selected_entry(tmp_path: Path) -> None: def test_build_render_lines_marks_selected_entry(tmp_path: Path) -> None:
workspace = tmp_path / "workspace" workspace = tmp_path / "workspace"
workspace.mkdir() workspace.mkdir()
@@ -106,7 +121,7 @@ def test_toggle_history_mode_selects_current_history_entry(tmp_path: Path) -> No
ui._toggle_history_mode(navigator) ui._toggle_history_mode(navigator)
assert ui._history_mode is True assert ui._history_mode is True
assert ui._history_selected_index == 1 assert ui._history_selected_index == 0
def test_history_mode_enter_selects_entry_and_exits(tmp_path: Path) -> None: def test_history_mode_enter_selects_entry_and_exits(tmp_path: Path) -> None:
@@ -126,7 +141,7 @@ def test_history_mode_enter_selects_entry_and_exits(tmp_path: Path) -> None:
ui = TerminalUI() ui = TerminalUI()
ui._toggle_history_mode(navigator) ui._toggle_history_mode(navigator)
ui._handle_history_key(curses.KEY_UP, navigator) ui._handle_history_key(curses.KEY_DOWN, navigator)
ui._handle_history_key(10, navigator) ui._handle_history_key(10, navigator)
assert ui._history_mode is False assert ui._history_mode is False
@@ -134,6 +149,86 @@ def test_history_mode_enter_selects_entry_and_exits(tmp_path: Path) -> None:
assert navigator.selected_index == 0 assert navigator.selected_index == 0
def test_history_mode_renders_most_recent_entry_first(tmp_path: Path) -> None:
root = tmp_path / "root"
first = root / "first"
second = root / "second"
root.mkdir()
first.mkdir()
second.mkdir()
navigator = Navigator(root)
navigator.set_selected_index(1)
navigator.enter_selected_directory()
navigator.go_to_parent_directory()
navigator.set_selected_index(2)
navigator.enter_selected_directory()
ui = TerminalUI()
ui._toggle_history_mode(navigator)
lines, _selected_index = ui._build_history_lines_for_render(
navigator.history.entries()
)
assert lines[0].text == str(second.resolve())
def test_history_mode_slash_activates_history_filter() -> None:
ui = TerminalUI()
navigator = Navigator(Path.cwd())
ui._history_mode = True
ui._handle_history_key(ord("/"), navigator)
assert ui._history_filter_mode is True
assert ui._history_filter_query == ""
def test_history_filter_enter_selects_filtered_entry_and_exits_history(
tmp_path: Path,
) -> None:
root = tmp_path / "root"
alpha = root / "alpha"
beta = root / "beta"
root.mkdir()
alpha.mkdir()
beta.mkdir()
navigator = Navigator(root)
navigator.set_selected_index(1)
navigator.enter_selected_directory()
navigator.go_to_parent_directory()
navigator.set_selected_index(2)
navigator.enter_selected_directory()
ui = TerminalUI()
ui._toggle_history_mode(navigator)
ui._history_filter_mode = True
ui._history_filter_query = "alpha"
ui._handle_history_key(10, navigator)
assert navigator.current_path == alpha.resolve()
assert ui._history_mode is False
assert ui._history_filter_mode is False
def test_history_filter_escape_disables_only_history_filter(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
navigator = Navigator(root)
ui = TerminalUI()
ui._toggle_history_mode(navigator)
ui._history_filter_mode = True
ui._history_filter_query = "ro"
ui._handle_history_key(ESCAPE_KEY, navigator)
assert ui._history_mode is True
assert ui._history_filter_mode is False
assert ui._history_filter_query == ""
def test_history_mode_toggle_off_keeps_current_directory(tmp_path: Path) -> None: def test_history_mode_toggle_off_keeps_current_directory(tmp_path: Path) -> None:
root = tmp_path / "root" root = tmp_path / "root"
root.mkdir() root.mkdir()
@@ -221,6 +316,160 @@ def test_enter_returns_selected_path_without_navigating(tmp_path: Path) -> None:
assert navigator.current_path == workspace.resolve() assert navigator.current_path == workspace.resolve()
def test_toggle_files_key_updates_navigator_state(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
(root / "folder").mkdir()
(root / "notes.txt").write_text("content", encoding="utf-8")
navigator = Navigator(root)
ui = TerminalUI()
assert navigator.show_files is False
assert ui._handle_tree_key(ord("a"), navigator) is None
assert navigator.show_files is True
def test_filter_matching_is_case_insensitive(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
(root / "Alpha").mkdir()
(root / "beta").mkdir()
navigator = Navigator(root)
ui = TerminalUI()
ui._filter_mode = True
ui._filter_query = "alp"
matches = ui._matching_indices(navigator.visible_entries)
assert matches == [1]
def test_build_status_line_shows_filter_off_when_filter_mode_is_disabled(
tmp_path: Path,
) -> None:
root = tmp_path / "root"
root.mkdir()
navigator = Navigator(root)
status_line = build_status_line(
navigator,
filter_mode=False,
filter_query="",
dev_summary="git:- stack:-",
)
assert "filter: off" in status_line
def test_build_status_line_shows_empty_filter_without_off_when_filter_is_active(
tmp_path: Path,
) -> None:
root = tmp_path / "root"
root.mkdir()
navigator = Navigator(root)
status_line = build_status_line(
navigator,
filter_mode=True,
filter_query="",
dev_summary="git:- stack:-",
)
assert "filter: |" in status_line
def test_filter_mode_uses_g_and_g_upper_as_query_characters(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
(root / "alpha").mkdir()
(root / "gamma").mkdir()
navigator = Navigator(root)
ui = TerminalUI()
ui._filter_mode = True
assert ui._handle_filter_key(ord("g"), navigator) is None
assert ui._handle_filter_key(ord("G"), navigator) is None
assert ui._filter_query == "gG"
def test_filter_enter_on_directory_navigates_and_exits_filter(tmp_path: Path) -> None:
root = tmp_path / "root"
child = root / "child"
root.mkdir()
child.mkdir()
navigator = Navigator(root)
ui = TerminalUI()
ui._filter_mode = True
ui._filter_query = "child"
result = ui._handle_filter_key(10, navigator)
assert result is None
assert ui._filter_mode is False
assert navigator.current_path == child.resolve()
def test_filter_enter_on_file_opens_menu_and_exits_filter(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
root = tmp_path / "root"
root.mkdir()
file_path = root / "notes.txt"
file_path.write_text("hello", encoding="utf-8")
navigator = Navigator(root)
navigator.toggle_files()
ui = TerminalUI()
ui._filter_mode = True
ui._filter_query = "notes"
opened: dict[str, bool] = {"called": False}
def fake_open_with_selected(
_navigator: Navigator, _stdscr: curses.window | None
) -> None:
opened["called"] = True
monkeypatch.setattr(ui, "_open_with_selected", fake_open_with_selected)
result = ui._handle_filter_key(10, navigator)
assert result is None
assert ui._filter_mode is False
assert opened["called"] is True
assert navigator.selected_entry.path == file_path.resolve()
def test_end_key_jumps_to_last_entry(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
for name in ("alpha", "beta", "gamma"):
(root / name).mkdir()
navigator = Navigator(root)
ui = TerminalUI()
assert ui._handle_tree_key(curses.KEY_END, navigator) is None
assert navigator.selected_index == len(navigator.visible_entries) - 1
def test_info_mode_toggles_with_i_and_escape(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
navigator = Navigator(root)
ui = TerminalUI()
assert ui._info_mode is False
assert ui._handle_tree_key(ord("i"), navigator) is None
assert ui._info_mode is True
ui._handle_info_key(ESCAPE_KEY)
assert ui._info_mode is False
def test_open_terminal_streams_raises_without_terminal( def test_open_terminal_streams_raises_without_terminal(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None: