diff --git a/CHANGELOG.md b/CHANGELOG.md index 2acaa5c..6c70897 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index e7bfd4c..d398edc 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/logo-galicia-ansi-demo.zsh b/docs/logo-galicia-ansi-demo.zsh new file mode 100755 index 0000000..468cd21 --- /dev/null +++ b/docs/logo-galicia-ansi-demo.zsh @@ -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 " ███ ███ ███ █ ██ █ █ ██ ██ █" diff --git a/pyproject.toml b/pyproject.toml index 11a211f..c131970 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/app/devinfo.py b/src/app/devinfo.py new file mode 100644 index 0000000..76b7b98 --- /dev/null +++ b/src/app/devinfo.py @@ -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 diff --git a/src/app/opener.py b/src/app/opener.py index 7a4d889..0c4606f 100644 --- a/src/app/opener.py +++ b/src/app/opener.py @@ -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: diff --git a/src/app/ui.py b/src/app/ui.py index 5286422..2c3543a 100644 --- a/src/app/ui.py +++ b/src/app/ui.py @@ -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 diff --git a/tests/test_opener.py b/tests/test_opener.py index e2285f4..bdbfdd1 100644 --- a/tests/test_opener.py +++ b/tests/test_opener.py @@ -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: diff --git a/tests/test_ui.py b/tests/test_ui.py index 6a85032..8de2bf4 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -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: