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 80715e1dec
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. 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 ## [0.2.6] - 2026-04-04
### Added ### Added

View File

@@ -23,12 +23,15 @@ Working in the terminal often means:
- ⌨️ Fully keyboard-driven workflow (`↑` / `↓` / `←` / `→`) - ⌨️ Fully keyboard-driven workflow (`↑` / `↓` / `←` / `→`)
- 🌲 Expand and collapse directories (`→` to expand/enter, `←` to collapse/back) - 🌲 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`) - 🔎 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 - 📄 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) - ⚡ 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 - 🚀 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`) - 🔁 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 `.` - 👀 Toggle hidden entries with `.`
- 🖥 Native terminal interface - 🖥 Native terminal interface
- 🔁 Works with Bash, Zsh and other shells - 🔁 Works with Bash, Zsh and other shells
@@ -49,6 +52,7 @@ Normal mode:
- `a`: Toggle file visibility - `a`: Toggle file visibility
- `/`: Enter filter mode - `/`: Enter filter mode
- `o`: Open selected entry with an app - `o`: Open selected entry with an app
- `i`: Toggle development info panel
- `PgUp` / `PgDn`: Page jump - `PgUp` / `PgDn`: Page jump
- `Home` / `End`: Jump to first/last entry - `Home` / `End`: Jump to first/last entry
- `g` / `G`: Jump to first/last entry - `g` / `G`: Jump to first/last entry
@@ -65,10 +69,19 @@ Filter mode:
History mode: History mode:
- `↑` / `↓`: Move through history entries - `↑` / `↓`: Move through history entries (most recent at top)
- `Enter`: Jump to selected history path - `Enter`: Jump to selected history path
- `Esc` or `h`: Exit history mode - `Esc` or `h`: Exit history mode
- `PgUp` / `PgDn`, `Home` / `End`: Fast history navigation - `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`): Open with menu (`o`):
@@ -79,7 +92,26 @@ Open with menu (`o`):
The menu only shows apps available in your system (`PATH`) and context: The menu only shows apps available in your system (`PATH`) and context:
- GUI options (when GUI is available): `code`, system `open`/`xdg-open` - 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 ## 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] [project]
name = "cd-browser" name = "cd-browser"
version = "0.2.6" 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"

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 subprocess
import sys import sys
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
try:
import tomllib
except ModuleNotFoundError: # pragma: no cover - python < 3.11
tomllib = None # type: ignore[assignment]
LaunchMode = Literal["background", "blocking"] 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) @dataclass(frozen=True, slots=True)
@@ -31,8 +39,11 @@ def available_openers(path: Path) -> list[OpenWithOption]:
is_directory = path.is_dir() is_directory = path.is_dir()
has_gui = _has_gui_session() has_gui = _has_gui_session()
options: list[OpenWithOption] = [] 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: if is_directory and not option.supports_directories:
continue continue
if not is_directory and not option.supports_files: 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" return option.launch_mode == "blocking"
def _default_options() -> tuple[OpenWithOption, ...]: def _options_catalog() -> dict[str, OpenWithOption]:
return ( return {
OpenWithOption( "code": OpenWithOption(
label="VS Code", label="VS Code",
command_template="code {path}", command_template="code {path}",
executable="code", executable="code",
launch_mode="background", launch_mode="background",
requires_gui=True, requires_gui=True,
), ),
OpenWithOption( "open": OpenWithOption(
label="Open (system)", label="Open (system)",
command_template=_system_open_template(), command_template=_system_open_template(),
executable=_system_open_executable(), executable=_system_open_executable(),
@@ -106,40 +117,151 @@ def _default_options() -> tuple[OpenWithOption, ...]:
requires_gui=True, requires_gui=True,
supports_directories=False, supports_directories=False,
), ),
OpenWithOption( "opencode": OpenWithOption(
label="OpenCode",
command_template="opencode {path}",
executable="opencode",
launch_mode="background",
supports_files=False,
),
"antigravity": OpenWithOption(
label="Antigravity", label="Antigravity",
command_template="antigravity {path}", command_template="antigravity {path}",
executable="antigravity", executable="antigravity",
launch_mode="background", launch_mode="background",
), ),
OpenWithOption( "nvim": OpenWithOption(
label="Neovim", label="Neovim",
command_template="nvim {path}", command_template="nvim {path}",
executable="nvim", executable="nvim",
launch_mode="blocking", launch_mode="blocking",
), ),
OpenWithOption( "nano": OpenWithOption(
label="Nano", label="Nano",
command_template="nano {path}", command_template="nano {path}",
executable="nano", executable="nano",
launch_mode="blocking", launch_mode="blocking",
supports_directories=False, supports_directories=False,
), ),
OpenWithOption( "less": OpenWithOption(
label="Less", label="Less",
command_template="less {path}", command_template="less {path}",
executable="less", executable="less",
launch_mode="blocking", launch_mode="blocking",
supports_directories=False, supports_directories=False,
), ),
OpenWithOption( "bat": OpenWithOption(
label="Bat", label="Bat",
command_template="bat {path}", command_template="bat {path}",
executable="bat", executable="bat",
launch_mode="blocking", launch_mode="blocking",
supports_directories=False, 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: def _has_gui_session() -> bool:

View File

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

View File

@@ -7,6 +7,7 @@ import pytest
from app.opener import ( from app.opener import (
OpenWithOption, OpenWithOption,
_reset_openers_config_cache,
available_openers, available_openers,
is_blocking_option, is_blocking_option,
launch_background_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( def test_available_openers_headless_file_excludes_gui(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None: ) -> None:
@@ -65,6 +71,107 @@ def test_available_openers_gui_directory_filters_file_only_tools(
assert labels == ["VS Code", "Antigravity", "Neovim"] 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( def test_launch_background_option_returns_success(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None: ) -> 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) 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:
@@ -141,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
@@ -149,6 +149,30 @@ 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: def test_history_mode_slash_activates_history_filter() -> None:
ui = TerminalUI() ui = TerminalUI()
navigator = Navigator(Path.cwd()) navigator = Navigator(Path.cwd())
@@ -329,7 +353,12 @@ def test_build_status_line_shows_filter_off_when_filter_mode_is_disabled(
root.mkdir() root.mkdir()
navigator = Navigator(root) 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 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() root.mkdir()
navigator = Navigator(root) 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 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 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: