release: 0.3.0 persistent history, configurable openers, and dev info panel

This commit is contained in:
2026-04-06 01:09:07 +02:00
parent beab56393a
commit c9f08895be
9 changed files with 664 additions and 31 deletions

View File

@@ -4,6 +4,25 @@ 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.
## [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

View File

@@ -23,12 +23,15 @@ Working in the terminal often means:
- ⌨️ Fully keyboard-driven workflow (`↑` / `↓` / `←` / `→`)
- 🌲 Expand and collapse directories (`→` to expand/enter, `←` to collapse/back)
- 📜 Navigation history inside the session (`h` history mode, `b` back, `f` forward)
- 📜 Persistent history between sessions (`h` history mode, `b` back, `f` forward)
- 🔎 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
- 🔁 Works with Bash, Zsh and other shells
@@ -49,6 +52,7 @@ Normal mode:
- `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
@@ -65,10 +69,19 @@ Filter mode:
History mode:
- `↑` / `↓`: Move through history entries
- `↑` / `↓`: 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`):
@@ -79,7 +92,26 @@ Open with menu (`o`):
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`, `nvim`, `nano`, `less`, `bat` (as applicable)
- 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

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]
name = "cd-browser"
version = "0.2.6"
version = "0.3.0"
description = "A fast keyboard-driven directory navigator for the terminal."
readme = "README.md"
requires-python = ">=3.8"

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

@@ -6,10 +6,18 @@ 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)
@@ -31,8 +39,11 @@ def available_openers(path: Path) -> list[OpenWithOption]:
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 in _default_options():
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:
@@ -89,16 +100,16 @@ def is_blocking_option(option: OpenWithOption) -> bool:
return option.launch_mode == "blocking"
def _default_options() -> tuple[OpenWithOption, ...]:
return (
OpenWithOption(
def _options_catalog() -> dict[str, OpenWithOption]:
return {
"code": OpenWithOption(
label="VS Code",
command_template="code {path}",
executable="code",
launch_mode="background",
requires_gui=True,
),
OpenWithOption(
"open": OpenWithOption(
label="Open (system)",
command_template=_system_open_template(),
executable=_system_open_executable(),
@@ -106,40 +117,151 @@ def _default_options() -> tuple[OpenWithOption, ...]:
requires_gui=True,
supports_directories=False,
),
OpenWithOption(
"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",
),
OpenWithOption(
"nvim": OpenWithOption(
label="Neovim",
command_template="nvim {path}",
executable="nvim",
launch_mode="blocking",
),
OpenWithOption(
"nano": OpenWithOption(
label="Nano",
command_template="nano {path}",
executable="nano",
launch_mode="blocking",
supports_directories=False,
),
OpenWithOption(
"less": OpenWithOption(
label="Less",
command_template="less {path}",
executable="less",
launch_mode="blocking",
supports_directories=False,
),
OpenWithOption(
"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:

View File

@@ -10,6 +10,7 @@ from importlib import metadata
from pathlib import Path
from typing import TextIO
from app.devinfo import DevInfoCache, DevSnapshot
from app.navigator import Navigator, VisibleEntry
from app.opener import (
OpenWithOption,
@@ -102,7 +103,7 @@ def build_history_lines(
def build_status_line(
navigator: Navigator, filter_mode: bool, filter_query: str
navigator: Navigator, filter_mode: bool, filter_query: str, dev_summary: str
) -> str:
"""Build first header line with version and live session toggles."""
@@ -115,7 +116,8 @@ def build_status_line(
history_count = len(navigator.history.entries())
return (
f"cd-browser v{_app_version()} | hist: {history_count} | "
f"{filter_segment} | hidden: {hidden_status} | files: {file_status}"
f"{filter_segment} | hidden: {hidden_status} | files: {file_status} | "
f"dev: {dev_summary}"
)
@@ -157,6 +159,7 @@ class TerminalUI:
self._history_filter_query = ""
self._history_filter_selected_index = 0
self._history_filter_scroll_offset = 0
self._info_mode = False
self._filter_mode = False
self._filter_query = ""
self._filter_selected_index = 0
@@ -164,6 +167,7 @@ class TerminalUI:
self._status_message: str | None = None
self._normal_attr = curses.A_NORMAL
self._filter_label_attr = curses.A_BOLD
self._dev_info = DevInfoCache()
def run(self, navigator: Navigator) -> Path:
"""Start the interactive session and return the final directory path."""
@@ -182,6 +186,10 @@ class TerminalUI:
self._render(stdscr, navigator)
key = stdscr.getch()
if self._info_mode:
self._handle_info_key(key)
continue
if self._history_mode:
self._handle_history_key(key, navigator, stdscr=stdscr)
continue
@@ -207,14 +215,35 @@ class TerminalUI:
stdscr.attrset(self._normal_attr)
stdscr.erase()
height, width = stdscr.getmaxyx()
snapshot = self._dev_info.get(navigator.current_path)
if self._history_mode:
self._render_history_mode(stdscr, navigator, width=width, height=height)
self._render_history_mode(
stdscr,
navigator,
snapshot=snapshot,
width=width,
height=height,
)
stdscr.refresh()
return
if self._info_mode:
self._render_info_mode(
stdscr,
navigator,
snapshot=snapshot,
width=width,
height=height,
)
stdscr.refresh()
return
status_line = build_status_line(
navigator, filter_mode=self._filter_mode, filter_query=self._filter_query
navigator,
filter_mode=self._filter_mode,
filter_query=self._filter_query,
dev_summary=snapshot.header_summary,
)
path_line = build_path_line(navigator.current_path)
self._render_status_line(
@@ -309,13 +338,19 @@ class TerminalUI:
], self._filter_selected_index
def _render_history_mode(
self, stdscr: curses.window, navigator: Navigator, width: int, height: int
self,
stdscr: curses.window,
navigator: Navigator,
snapshot: DevSnapshot,
width: int,
height: int,
) -> None:
stdscr.attrset(self._normal_attr)
status_line = build_status_line(
navigator,
filter_mode=self._history_filter_mode,
filter_query=self._history_filter_query,
dev_summary=snapshot.header_summary,
)
path_line = build_path_line(navigator.current_path)
self._render_status_line(
@@ -415,6 +450,38 @@ class TerminalUI:
self._history_filter_selected_index = 0
self._history_filter_scroll_offset = 0
def _render_info_mode(
self,
stdscr: curses.window,
navigator: Navigator,
snapshot: DevSnapshot,
width: int,
height: int,
) -> None:
status_line = build_status_line(
navigator,
filter_mode=False,
filter_query="",
dev_summary=snapshot.header_summary,
)
path_line = build_path_line(navigator.current_path)
self._render_status_line(stdscr, 0, width, status_line, filter_active=False)
stdscr.addnstr(
1, 0, path_line, max(0, width - 1), self._normal_attr | curses.A_DIM
)
stdscr.addnstr(
2,
0,
"Dev info (Esc/i to close)",
max(0, width - 1),
self._normal_attr | curses.A_BOLD,
)
detail_lines = snapshot.detail_lines(navigator.current_path)
for row, line in enumerate(detail_lines, start=3):
if row >= height:
break
stdscr.addnstr(row, 0, line, max(0, width - 1), self._normal_attr)
def _initialize_colors(self) -> None:
self._normal_attr = curses.A_NORMAL
self._filter_label_attr = curses.A_BOLD
@@ -565,6 +632,15 @@ class TerminalUI:
self._history_filter_scroll_offset = 0
return
if key == ord("i"):
self._history_mode = False
self._history_filter_mode = False
self._history_filter_query = ""
self._history_filter_selected_index = 0
self._history_filter_scroll_offset = 0
self._info_mode = True
return
if key == ord("/"):
self._history_filter_mode = True
self._history_filter_query = ""
@@ -615,7 +691,10 @@ class TerminalUI:
return
if key in (curses.KEY_ENTER, 10, 13) and history_entries:
navigator.select_history_entry(self._history_selected_index)
selected_entry_index = self._history_display_indices(history_entries)[
self._history_selected_index
]
navigator.select_history_entry(selected_entry_index)
self._history_mode = False
self._history_filter_mode = False
self._history_filter_query = ""
@@ -824,11 +903,19 @@ class TerminalUI:
self._open_with_selected(navigator, stdscr)
return None
if key == ord("i"):
self._info_mode = True
return None
if key in (curses.KEY_ENTER, 10, 13):
return navigator.selected_entry.path
return None
def _handle_info_key(self, key: int) -> None:
if key in (ESCAPE_KEY, ord("i")):
self._info_mode = False
def _matching_indices(self, entries: tuple[VisibleEntry, ...]) -> list[int]:
if not self._filter_query:
return list(range(len(entries)))
@@ -841,23 +928,36 @@ class TerminalUI:
]
def _matching_history_indices(self, entries: tuple[Path, ...]) -> list[int]:
display_indices = self._history_display_indices(entries)
if not self._history_filter_query:
return list(range(len(entries)))
return display_indices
query = self._history_filter_query.casefold()
return [
index
for index, entry in enumerate(entries)
if query in str(entry).casefold()
entry_index
for entry_index in display_indices
if query in str(entries[entry_index]).casefold()
]
def _build_history_lines_for_render(
self, entries: tuple[Path, ...]
) -> tuple[list[HistoryLine], int]:
if not self._history_filter_mode:
return build_history_lines(entries, self._history_selected_index), (
self._history_selected_index
display_indices = self._history_display_indices(entries)
if not display_indices:
self._history_selected_index = 0
return [], 0
self._history_selected_index = max(
0, min(self._history_selected_index, len(display_indices) - 1)
)
return [
HistoryLine(
text=str(entries[entry_index]),
is_selected=index == self._history_selected_index,
)
for index, entry_index in enumerate(display_indices)
], self._history_selected_index
matches = self._matching_history_indices(entries)
if not matches:
@@ -875,6 +975,9 @@ class TerminalUI:
for match_index, entry_index in enumerate(matches)
], self._history_filter_selected_index
def _history_display_indices(self, entries: tuple[Path, ...]) -> list[int]:
return list(range(len(entries) - 1, -1, -1))
def _page_step(self, stdscr: curses.window | None, start_row: int) -> int:
if stdscr is None:
return 10
@@ -1009,7 +1112,7 @@ class TerminalUI:
for index in range(len(history_entries) - 1, -1, -1):
if history_entries[index] == navigator.current_path:
return index
return len(history_entries) - index - 1
return 0

View File

@@ -7,6 +7,7 @@ import pytest
from app.opener import (
OpenWithOption,
_reset_openers_config_cache,
available_openers,
is_blocking_option,
launch_background_option,
@@ -14,6 +15,11 @@ from app.opener import (
)
@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:
@@ -65,6 +71,107 @@ def test_available_openers_gui_directory_filters_file_only_tools(
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:

View File

@@ -121,7 +121,7 @@ def test_toggle_history_mode_selects_current_history_entry(tmp_path: Path) -> No
ui._toggle_history_mode(navigator)
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:
@@ -141,7 +141,7 @@ def test_history_mode_enter_selects_entry_and_exits(tmp_path: Path) -> None:
ui = TerminalUI()
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)
assert ui._history_mode is False
@@ -149,6 +149,30 @@ def test_history_mode_enter_selects_entry_and_exits(tmp_path: Path) -> None:
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())
@@ -329,7 +353,12 @@ def test_build_status_line_shows_filter_off_when_filter_mode_is_disabled(
root.mkdir()
navigator = Navigator(root)
status_line = build_status_line(navigator, filter_mode=False, filter_query="")
status_line = build_status_line(
navigator,
filter_mode=False,
filter_query="",
dev_summary="git:- stack:-",
)
assert "filter: off" in status_line
@@ -341,7 +370,12 @@ def test_build_status_line_shows_empty_filter_without_off_when_filter_is_active(
root.mkdir()
navigator = Navigator(root)
status_line = build_status_line(navigator, filter_mode=True, filter_query="")
status_line = build_status_line(
navigator,
filter_mode=True,
filter_query="",
dev_summary="git:- stack:-",
)
assert "filter: |" in status_line
@@ -422,6 +456,20 @@ def test_end_key_jumps_to_last_entry(tmp_path: Path) -> 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(
monkeypatch: pytest.MonkeyPatch,
) -> None: