Compare commits
2 Commits
6f78f6d9ca
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c9f08895be | |||
| beab56393a |
19
CHANGELOG.md
19
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
|
||||
|
||||
38
README.md
38
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
|
||||
|
||||
|
||||
34
docs/logo-galicia-ansi-demo.zsh
Executable file
34
docs/logo-galicia-ansi-demo.zsh
Executable 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 " ███ ███ ███ █ ██ █ █ ██ ██ █"
|
||||
@@ -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"
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.history import SessionHistory
|
||||
from app.navigator import Navigator
|
||||
from app.ui import TerminalUI
|
||||
|
||||
@@ -10,6 +11,6 @@ def run_cli(start_path: Path | None = None, ui: TerminalUI | None = None) -> Pat
|
||||
"""Run the interactive directory browser and return the selected path."""
|
||||
|
||||
initial_path = (start_path or Path.cwd()).expanduser().resolve()
|
||||
navigator = Navigator(initial_path)
|
||||
navigator = Navigator(initial_path, history=SessionHistory.persistent())
|
||||
terminal_ui = ui or TerminalUI()
|
||||
return terminal_ui.run(navigator)
|
||||
|
||||
168
src/app/devinfo.py
Normal file
168
src/app/devinfo.py
Normal 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
|
||||
@@ -1,15 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def default_history_path() -> Path:
|
||||
"""Return the default on-disk history location."""
|
||||
|
||||
override = os.environ.get("CD_BROWSER_HISTORY_FILE")
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
|
||||
if os.name == "nt":
|
||||
base = Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming")))
|
||||
else:
|
||||
base = Path(os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local/state")))
|
||||
|
||||
return base / "cd-browser" / "history.txt"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SessionHistory:
|
||||
"""In-memory session history for visited directories."""
|
||||
|
||||
_entries: list[Path] = field(default_factory=list)
|
||||
_index: int = -1
|
||||
storage_path: Path | None = None
|
||||
max_entries: int = 500
|
||||
|
||||
@classmethod
|
||||
def persistent(
|
||||
cls, storage_path: Path | None = None, max_entries: int = 500
|
||||
) -> SessionHistory:
|
||||
"""Create a history instance backed by an on-disk file."""
|
||||
|
||||
history = cls(
|
||||
storage_path=storage_path or default_history_path(),
|
||||
max_entries=max_entries,
|
||||
)
|
||||
history._load()
|
||||
return history
|
||||
|
||||
def visit(self, path: Path) -> Path:
|
||||
"""Record a directory visit and discard forward history when needed."""
|
||||
@@ -23,7 +54,9 @@ class SessionHistory:
|
||||
self._entries = self._entries[: self._index + 1]
|
||||
|
||||
self._entries.append(resolved_path)
|
||||
self._trim_to_limit()
|
||||
self._index = len(self._entries) - 1
|
||||
self._persist()
|
||||
return resolved_path
|
||||
|
||||
@property
|
||||
@@ -66,3 +99,48 @@ class SessionHistory:
|
||||
"""Return the full history as an immutable sequence."""
|
||||
|
||||
return tuple(self._entries)
|
||||
|
||||
def _load(self) -> None:
|
||||
if self.storage_path is None:
|
||||
return
|
||||
|
||||
try:
|
||||
lines = self.storage_path.read_text(encoding="utf-8").splitlines()
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError:
|
||||
return
|
||||
|
||||
loaded_entries: list[Path] = []
|
||||
for line in lines:
|
||||
raw = line.strip()
|
||||
if not raw:
|
||||
continue
|
||||
|
||||
resolved = Path(raw).expanduser().resolve()
|
||||
if loaded_entries and loaded_entries[-1] == resolved:
|
||||
continue
|
||||
|
||||
loaded_entries.append(resolved)
|
||||
|
||||
self._entries = loaded_entries
|
||||
self._trim_to_limit()
|
||||
self._index = len(self._entries) - 1
|
||||
|
||||
def _persist(self) -> None:
|
||||
if self.storage_path is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
contents = "".join(f"{entry}\n" for entry in self._entries)
|
||||
self.storage_path.write_text(contents, encoding="utf-8")
|
||||
except OSError:
|
||||
return
|
||||
|
||||
def _trim_to_limit(self) -> None:
|
||||
limit = max(1, self.max_entries)
|
||||
if len(self._entries) <= limit:
|
||||
return
|
||||
|
||||
self._entries = self._entries[-limit:]
|
||||
|
||||
@@ -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:
|
||||
|
||||
484
src/app/ui.py
484
src/app/ui.py
@@ -6,9 +6,11 @@ import sys
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
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,
|
||||
@@ -20,6 +22,18 @@ from app.opener import (
|
||||
|
||||
ESCAPE_KEY = 27
|
||||
BACKSPACE_KEYS = (curses.KEY_BACKSPACE, 127, 8)
|
||||
DEFAULT_APP_VERSION = "0.3.0"
|
||||
FILTER_COLOR_PAIR = 1
|
||||
NORMAL_COLOR_PAIR = 2
|
||||
|
||||
|
||||
def _app_version() -> str:
|
||||
"""Return installed package version when available."""
|
||||
|
||||
try:
|
||||
return metadata.version("cd-browser")
|
||||
except metadata.PackageNotFoundError:
|
||||
return DEFAULT_APP_VERSION
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -88,6 +102,31 @@ def build_history_lines(
|
||||
]
|
||||
|
||||
|
||||
def build_status_line(
|
||||
navigator: Navigator, filter_mode: bool, filter_query: str, dev_summary: str
|
||||
) -> str:
|
||||
"""Build first header line with version and live session toggles."""
|
||||
|
||||
hidden_status = "on" if navigator.show_hidden else "off"
|
||||
file_status = "on" if navigator.show_files else "off"
|
||||
if filter_mode:
|
||||
filter_segment = f"filter: {filter_query}" if filter_query else "filter:"
|
||||
else:
|
||||
filter_segment = "filter: off"
|
||||
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"dev: {dev_summary}"
|
||||
)
|
||||
|
||||
|
||||
def build_path_line(path: Path) -> str:
|
||||
"""Build second header line with current path."""
|
||||
|
||||
return f"path: {path}"
|
||||
|
||||
|
||||
def clamp_scroll_offset(
|
||||
selected_index: int, scroll_offset: int, visible_count: int, total_count: int
|
||||
) -> int:
|
||||
@@ -116,11 +155,19 @@ class TerminalUI:
|
||||
self._history_selected_index = 0
|
||||
self._tree_scroll_offset = 0
|
||||
self._history_scroll_offset = 0
|
||||
self._history_filter_mode = False
|
||||
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
|
||||
self._filter_scroll_offset = 0
|
||||
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."""
|
||||
@@ -132,12 +179,17 @@ class TerminalUI:
|
||||
def _run_session(self, stdscr: curses.window, navigator: Navigator) -> Path:
|
||||
curses.curs_set(0)
|
||||
stdscr.keypad(True)
|
||||
self._initialize_colors()
|
||||
original_path = navigator.current_path
|
||||
|
||||
while True:
|
||||
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
|
||||
@@ -160,31 +212,51 @@ class TerminalUI:
|
||||
return tree_result
|
||||
|
||||
def _render(self, stdscr: curses.window, navigator: Navigator) -> None:
|
||||
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
|
||||
|
||||
hidden_status = "on" if navigator.show_hidden else "off"
|
||||
file_status = "on" if navigator.show_files else "off"
|
||||
path_text = (
|
||||
f"{navigator.current_path} [hidden: {hidden_status}] [files: {file_status}]"
|
||||
)
|
||||
stdscr.addnstr(0, 0, path_text, max(0, width - 1))
|
||||
|
||||
start_row = 2
|
||||
if self._filter_mode:
|
||||
stdscr.addnstr(
|
||||
1,
|
||||
0,
|
||||
f"Filter: {self._filter_query}",
|
||||
max(0, width - 1),
|
||||
curses.A_BOLD,
|
||||
if self._info_mode:
|
||||
self._render_info_mode(
|
||||
stdscr,
|
||||
navigator,
|
||||
snapshot=snapshot,
|
||||
width=width,
|
||||
height=height,
|
||||
)
|
||||
start_row = 3
|
||||
stdscr.refresh()
|
||||
return
|
||||
|
||||
status_line = build_status_line(
|
||||
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(
|
||||
stdscr,
|
||||
0,
|
||||
width,
|
||||
status_line,
|
||||
filter_active=self._filter_mode,
|
||||
)
|
||||
stdscr.addnstr(
|
||||
1, 0, path_line, max(0, width - 1), self._normal_attr | curses.A_DIM
|
||||
)
|
||||
start_row = 2
|
||||
|
||||
lines, selected_index = self._build_tree_lines_for_render(navigator)
|
||||
footer_rows = 1 if self._status_message else 0
|
||||
@@ -208,7 +280,13 @@ class TerminalUI:
|
||||
scroll_offset = self._tree_scroll_offset
|
||||
|
||||
if self._filter_mode and self._filter_query and not lines:
|
||||
stdscr.addnstr(start_row, 0, "No matches", max(0, width - 1), curses.A_DIM)
|
||||
stdscr.addnstr(
|
||||
start_row,
|
||||
0,
|
||||
"No matches",
|
||||
max(0, width - 1),
|
||||
self._normal_attr | curses.A_DIM,
|
||||
)
|
||||
else:
|
||||
for row, line in enumerate(
|
||||
lines[scroll_offset : scroll_offset + visible_count],
|
||||
@@ -217,7 +295,11 @@ class TerminalUI:
|
||||
if row >= height:
|
||||
break
|
||||
|
||||
attributes = curses.A_REVERSE if line.is_selected else curses.A_NORMAL
|
||||
attributes = (
|
||||
self._normal_attr | curses.A_REVERSE
|
||||
if line.is_selected
|
||||
else self._normal_attr
|
||||
)
|
||||
stdscr.addnstr(row, 0, line.text, max(0, width - 1), attributes)
|
||||
|
||||
if self._status_message and height > 0:
|
||||
@@ -226,7 +308,7 @@ class TerminalUI:
|
||||
0,
|
||||
self._status_message,
|
||||
max(0, width - 1),
|
||||
curses.A_DIM,
|
||||
self._normal_attr | curses.A_DIM,
|
||||
)
|
||||
|
||||
stdscr.refresh()
|
||||
@@ -256,44 +338,99 @@ 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:
|
||||
history_lines = build_history_lines(
|
||||
navigator.history.entries(), self._history_selected_index
|
||||
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,
|
||||
)
|
||||
start_row = 1
|
||||
path_line = build_path_line(navigator.current_path)
|
||||
self._render_status_line(
|
||||
stdscr,
|
||||
0,
|
||||
width,
|
||||
status_line,
|
||||
filter_active=self._history_filter_mode,
|
||||
)
|
||||
stdscr.addnstr(
|
||||
1, 0, path_line, max(0, width - 1), self._normal_attr | curses.A_DIM
|
||||
)
|
||||
|
||||
history_entries = navigator.history.entries()
|
||||
history_lines, selected_index = self._build_history_lines_for_render(
|
||||
history_entries
|
||||
)
|
||||
start_row = 2
|
||||
footer_rows = 1 if self._status_message else 0
|
||||
visible_count = max(0, height - start_row - 1 - footer_rows)
|
||||
self._history_scroll_offset = clamp_scroll_offset(
|
||||
self._history_selected_index,
|
||||
self._history_scroll_offset,
|
||||
visible_count,
|
||||
len(history_lines),
|
||||
if self._history_filter_mode:
|
||||
self._history_filter_scroll_offset = clamp_scroll_offset(
|
||||
selected_index,
|
||||
self._history_filter_scroll_offset,
|
||||
visible_count,
|
||||
len(history_lines),
|
||||
)
|
||||
scroll_offset = self._history_filter_scroll_offset
|
||||
else:
|
||||
self._history_scroll_offset = clamp_scroll_offset(
|
||||
selected_index,
|
||||
self._history_scroll_offset,
|
||||
visible_count,
|
||||
len(history_lines),
|
||||
)
|
||||
scroll_offset = self._history_scroll_offset
|
||||
|
||||
stdscr.addnstr(
|
||||
start_row,
|
||||
0,
|
||||
"History:",
|
||||
max(0, width - 1),
|
||||
self._normal_attr | curses.A_BOLD,
|
||||
)
|
||||
|
||||
stdscr.addnstr(start_row, 0, "History:", max(0, width - 1), curses.A_BOLD)
|
||||
|
||||
for index, line in enumerate(
|
||||
history_lines[
|
||||
self._history_scroll_offset : self._history_scroll_offset
|
||||
+ visible_count
|
||||
],
|
||||
history_lines[scroll_offset : scroll_offset + visible_count],
|
||||
start=1,
|
||||
):
|
||||
row = start_row + index
|
||||
if row >= height:
|
||||
break
|
||||
|
||||
attributes = curses.A_REVERSE if line.is_selected else curses.A_NORMAL
|
||||
attributes = (
|
||||
self._normal_attr | curses.A_REVERSE
|
||||
if line.is_selected
|
||||
else self._normal_attr
|
||||
)
|
||||
stdscr.addnstr(row, 0, line.text, max(0, width - 1), attributes)
|
||||
|
||||
if (
|
||||
self._history_filter_mode
|
||||
and self._history_filter_query
|
||||
and not history_lines
|
||||
):
|
||||
stdscr.addnstr(
|
||||
start_row + 1,
|
||||
0,
|
||||
"No matches",
|
||||
max(0, width - 1),
|
||||
self._normal_attr | curses.A_DIM,
|
||||
)
|
||||
|
||||
if self._status_message and height > 0:
|
||||
stdscr.addnstr(
|
||||
height - 1,
|
||||
0,
|
||||
self._status_message,
|
||||
max(0, width - 1),
|
||||
curses.A_DIM,
|
||||
self._normal_attr | curses.A_DIM,
|
||||
)
|
||||
|
||||
def _toggle_history_mode(self, navigator: Navigator) -> None:
|
||||
@@ -302,14 +439,213 @@ class TerminalUI:
|
||||
if self._history_mode:
|
||||
self._history_selected_index = self._get_current_history_index(navigator)
|
||||
self._history_scroll_offset = 0
|
||||
self._history_filter_mode = False
|
||||
self._history_filter_query = ""
|
||||
self._history_filter_selected_index = 0
|
||||
self._history_filter_scroll_offset = 0
|
||||
return
|
||||
|
||||
self._history_filter_mode = False
|
||||
self._history_filter_query = ""
|
||||
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
|
||||
if not curses.has_colors():
|
||||
return
|
||||
|
||||
try:
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
curses.init_pair(NORMAL_COLOR_PAIR, curses.COLOR_WHITE, -1)
|
||||
curses.init_pair(FILTER_COLOR_PAIR, curses.COLOR_GREEN, -1)
|
||||
self._normal_attr = curses.color_pair(NORMAL_COLOR_PAIR)
|
||||
self._filter_label_attr = (
|
||||
curses.color_pair(FILTER_COLOR_PAIR) | curses.A_BOLD
|
||||
)
|
||||
except curses.error:
|
||||
self._normal_attr = curses.A_NORMAL
|
||||
self._filter_label_attr = curses.A_BOLD
|
||||
|
||||
def _render_status_line(
|
||||
self,
|
||||
stdscr: curses.window,
|
||||
row: int,
|
||||
width: int,
|
||||
status_line: str,
|
||||
filter_active: bool,
|
||||
) -> None:
|
||||
max_width = max(0, width - 1)
|
||||
if not filter_active:
|
||||
stdscr.attrset(self._normal_attr)
|
||||
stdscr.addnstr(row, 0, status_line, max_width)
|
||||
stdscr.attrset(self._normal_attr)
|
||||
return
|
||||
|
||||
prefix, marker, suffix = status_line.partition("filter:")
|
||||
if not marker:
|
||||
stdscr.attrset(self._normal_attr)
|
||||
stdscr.addnstr(row, 0, status_line, max_width)
|
||||
stdscr.attrset(self._normal_attr)
|
||||
return
|
||||
|
||||
x = 0
|
||||
stdscr.attrset(self._normal_attr)
|
||||
stdscr.addnstr(row, x, prefix, max_width)
|
||||
x += min(len(prefix), max_width)
|
||||
if x >= max_width:
|
||||
return
|
||||
|
||||
marker_width = max_width - x
|
||||
stdscr.attrset(self._filter_label_attr)
|
||||
stdscr.addnstr(row, x, marker, marker_width)
|
||||
x += min(len(marker), marker_width)
|
||||
if x >= max_width:
|
||||
return
|
||||
|
||||
stdscr.attrset(self._normal_attr)
|
||||
stdscr.addnstr(row, x, suffix, max_width - x)
|
||||
stdscr.attrset(self._normal_attr)
|
||||
|
||||
def _handle_history_key(
|
||||
self, key: int, navigator: Navigator, stdscr: curses.window | None = None
|
||||
) -> None:
|
||||
history_entries = navigator.history.entries()
|
||||
matches = self._matching_history_indices(history_entries)
|
||||
|
||||
if self._history_filter_mode:
|
||||
if key == ESCAPE_KEY:
|
||||
self._history_filter_mode = False
|
||||
self._history_filter_query = ""
|
||||
self._history_filter_selected_index = 0
|
||||
self._history_filter_scroll_offset = 0
|
||||
return
|
||||
|
||||
if key in (curses.KEY_ENTER, 10, 13):
|
||||
if not matches:
|
||||
return
|
||||
|
||||
selected_entry_index = matches[self._history_filter_selected_index]
|
||||
navigator.select_history_entry(selected_entry_index)
|
||||
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._tree_scroll_offset = 0
|
||||
return
|
||||
|
||||
if key == curses.KEY_UP:
|
||||
self._history_filter_selected_index = move_selection(
|
||||
self._history_filter_selected_index,
|
||||
step=-1,
|
||||
entry_count=len(matches),
|
||||
)
|
||||
return
|
||||
|
||||
if key == curses.KEY_DOWN:
|
||||
self._history_filter_selected_index = move_selection(
|
||||
self._history_filter_selected_index,
|
||||
step=1,
|
||||
entry_count=len(matches),
|
||||
)
|
||||
return
|
||||
|
||||
if key == curses.KEY_PPAGE:
|
||||
step = -self._page_step(stdscr, start_row=3)
|
||||
self._history_filter_selected_index = move_selection(
|
||||
self._history_filter_selected_index,
|
||||
step=step,
|
||||
entry_count=len(matches),
|
||||
)
|
||||
return
|
||||
|
||||
if key == curses.KEY_NPAGE:
|
||||
step = self._page_step(stdscr, start_row=3)
|
||||
self._history_filter_selected_index = move_selection(
|
||||
self._history_filter_selected_index,
|
||||
step=step,
|
||||
entry_count=len(matches),
|
||||
)
|
||||
return
|
||||
|
||||
if key == curses.KEY_HOME:
|
||||
self._history_filter_selected_index = 0
|
||||
return
|
||||
|
||||
if key == curses.KEY_END and matches:
|
||||
self._history_filter_selected_index = len(matches) - 1
|
||||
return
|
||||
|
||||
if key in BACKSPACE_KEYS:
|
||||
self._history_filter_query = self._history_filter_query[:-1]
|
||||
self._history_filter_selected_index = 0
|
||||
self._history_filter_scroll_offset = 0
|
||||
return
|
||||
|
||||
if 32 <= key <= 126:
|
||||
self._history_filter_query += chr(key)
|
||||
self._history_filter_selected_index = 0
|
||||
self._history_filter_scroll_offset = 0
|
||||
|
||||
return
|
||||
|
||||
if key in (ESCAPE_KEY, ord("h")):
|
||||
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
|
||||
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 = ""
|
||||
self._history_filter_selected_index = 0
|
||||
self._history_filter_scroll_offset = 0
|
||||
return
|
||||
|
||||
if key == curses.KEY_UP:
|
||||
@@ -329,7 +665,7 @@ class TerminalUI:
|
||||
return
|
||||
|
||||
if key == curses.KEY_PPAGE:
|
||||
step = -self._page_step(stdscr, start_row=1)
|
||||
step = -self._page_step(stdscr, start_row=3)
|
||||
self._history_selected_index = move_selection(
|
||||
self._history_selected_index,
|
||||
step=step,
|
||||
@@ -338,7 +674,7 @@ class TerminalUI:
|
||||
return
|
||||
|
||||
if key == curses.KEY_NPAGE:
|
||||
step = self._page_step(stdscr, start_row=1)
|
||||
step = self._page_step(stdscr, start_row=3)
|
||||
self._history_selected_index = move_selection(
|
||||
self._history_selected_index,
|
||||
step=step,
|
||||
@@ -355,8 +691,15 @@ 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 = ""
|
||||
self._history_filter_selected_index = 0
|
||||
self._history_filter_scroll_offset = 0
|
||||
self._tree_scroll_offset = 0
|
||||
|
||||
def _handle_filter_key(
|
||||
@@ -560,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)))
|
||||
@@ -576,6 +927,57 @@ class TerminalUI:
|
||||
if query in entry.name.casefold()
|
||||
]
|
||||
|
||||
def _matching_history_indices(self, entries: tuple[Path, ...]) -> list[int]:
|
||||
display_indices = self._history_display_indices(entries)
|
||||
if not self._history_filter_query:
|
||||
return display_indices
|
||||
|
||||
query = self._history_filter_query.casefold()
|
||||
return [
|
||||
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:
|
||||
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:
|
||||
self._history_filter_selected_index = 0
|
||||
return [], 0
|
||||
|
||||
self._history_filter_selected_index = max(
|
||||
0, min(self._history_filter_selected_index, len(matches) - 1)
|
||||
)
|
||||
return [
|
||||
HistoryLine(
|
||||
text=str(entries[entry_index]),
|
||||
is_selected=match_index == self._history_filter_selected_index,
|
||||
)
|
||||
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
|
||||
@@ -710,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
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.history import SessionHistory
|
||||
from app.history import SessionHistory, default_history_path
|
||||
|
||||
|
||||
def test_visit_records_current_directory(tmp_path: Path) -> None:
|
||||
@@ -82,3 +82,48 @@ def test_select_raises_for_invalid_index() -> None:
|
||||
|
||||
with pytest.raises(IndexError):
|
||||
history.select(0)
|
||||
|
||||
|
||||
def test_persistent_history_loads_entries_from_disk(tmp_path: Path) -> None:
|
||||
first = tmp_path / "first"
|
||||
second = tmp_path / "second"
|
||||
for directory in (first, second):
|
||||
directory.mkdir()
|
||||
history_file = tmp_path / "history.txt"
|
||||
history_file.write_text(f"{first}\n{second}\n", encoding="utf-8")
|
||||
|
||||
history = SessionHistory.persistent(storage_path=history_file)
|
||||
|
||||
assert history.entries() == (first.resolve(), second.resolve())
|
||||
assert history.current == second.resolve()
|
||||
|
||||
|
||||
def test_persistent_history_visit_writes_to_disk_and_applies_limit(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
first = tmp_path / "first"
|
||||
second = tmp_path / "second"
|
||||
third = tmp_path / "third"
|
||||
for directory in (first, second, third):
|
||||
directory.mkdir()
|
||||
history_file = tmp_path / "state" / "history.txt"
|
||||
|
||||
history = SessionHistory.persistent(storage_path=history_file, max_entries=2)
|
||||
history.visit(first)
|
||||
history.visit(second)
|
||||
history.visit(third)
|
||||
|
||||
assert history.entries() == (second.resolve(), third.resolve())
|
||||
assert history_file.read_text(encoding="utf-8").splitlines() == [
|
||||
str(second.resolve()),
|
||||
str(third.resolve()),
|
||||
]
|
||||
|
||||
|
||||
def test_default_history_path_uses_override_env(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
override = tmp_path / "custom-history.txt"
|
||||
monkeypatch.setenv("CD_BROWSER_HISTORY_FILE", str(override))
|
||||
|
||||
assert default_history_path() == override
|
||||
|
||||
@@ -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:
|
||||
|
||||
134
tests/test_ui.py
134
tests/test_ui.py
@@ -5,11 +5,13 @@ import pytest
|
||||
|
||||
from app.navigator import Navigator, VisibleEntry
|
||||
from app.ui import (
|
||||
ESCAPE_KEY,
|
||||
HistoryLine,
|
||||
RenderLine,
|
||||
TerminalUI,
|
||||
build_history_lines,
|
||||
build_render_lines,
|
||||
build_status_line,
|
||||
clamp_scroll_offset,
|
||||
format_entry,
|
||||
move_selection,
|
||||
@@ -119,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:
|
||||
@@ -139,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
|
||||
@@ -147,6 +149,86 @@ 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())
|
||||
ui._history_mode = True
|
||||
|
||||
ui._handle_history_key(ord("/"), navigator)
|
||||
|
||||
assert ui._history_filter_mode is True
|
||||
assert ui._history_filter_query == ""
|
||||
|
||||
|
||||
def test_history_filter_enter_selects_filtered_entry_and_exits_history(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root = tmp_path / "root"
|
||||
alpha = root / "alpha"
|
||||
beta = root / "beta"
|
||||
root.mkdir()
|
||||
alpha.mkdir()
|
||||
beta.mkdir()
|
||||
|
||||
navigator = Navigator(root)
|
||||
navigator.set_selected_index(1)
|
||||
navigator.enter_selected_directory()
|
||||
navigator.go_to_parent_directory()
|
||||
navigator.set_selected_index(2)
|
||||
navigator.enter_selected_directory()
|
||||
|
||||
ui = TerminalUI()
|
||||
ui._toggle_history_mode(navigator)
|
||||
ui._history_filter_mode = True
|
||||
ui._history_filter_query = "alpha"
|
||||
|
||||
ui._handle_history_key(10, navigator)
|
||||
|
||||
assert navigator.current_path == alpha.resolve()
|
||||
assert ui._history_mode is False
|
||||
assert ui._history_filter_mode is False
|
||||
|
||||
|
||||
def test_history_filter_escape_disables_only_history_filter(tmp_path: Path) -> None:
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
navigator = Navigator(root)
|
||||
ui = TerminalUI()
|
||||
ui._toggle_history_mode(navigator)
|
||||
ui._history_filter_mode = True
|
||||
ui._history_filter_query = "ro"
|
||||
|
||||
ui._handle_history_key(ESCAPE_KEY, navigator)
|
||||
|
||||
assert ui._history_mode is True
|
||||
assert ui._history_filter_mode is False
|
||||
assert ui._history_filter_query == ""
|
||||
|
||||
|
||||
def test_history_mode_toggle_off_keeps_current_directory(tmp_path: Path) -> None:
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
@@ -264,6 +346,40 @@ def test_filter_matching_is_case_insensitive(tmp_path: Path) -> None:
|
||||
assert matches == [1]
|
||||
|
||||
|
||||
def test_build_status_line_shows_filter_off_when_filter_mode_is_disabled(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
navigator = Navigator(root)
|
||||
|
||||
status_line = build_status_line(
|
||||
navigator,
|
||||
filter_mode=False,
|
||||
filter_query="",
|
||||
dev_summary="git:- stack:-",
|
||||
)
|
||||
|
||||
assert "filter: off" in status_line
|
||||
|
||||
|
||||
def test_build_status_line_shows_empty_filter_without_off_when_filter_is_active(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
navigator = Navigator(root)
|
||||
|
||||
status_line = build_status_line(
|
||||
navigator,
|
||||
filter_mode=True,
|
||||
filter_query="",
|
||||
dev_summary="git:- stack:-",
|
||||
)
|
||||
|
||||
assert "filter: |" in status_line
|
||||
|
||||
|
||||
def test_filter_mode_uses_g_and_g_upper_as_query_characters(tmp_path: Path) -> None:
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
@@ -340,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:
|
||||
|
||||
Reference in New Issue
Block a user