release: 0.2.6 add advanced navigation and contextual open actions

This commit is contained in:
2026-04-04 01:53:00 +02:00
parent 8259b2e132
commit b68ba050de
11 changed files with 1090 additions and 50 deletions

View File

@@ -4,6 +4,22 @@ 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.2.6] - 2026-04-04
### Added
- Incremental filter workflow (`/`) with contextual Enter behavior:
- Directories navigate in-place
- Files open the app menu
- File visibility toggle (`a`) to switch between directories-only and mixed view
- Open-with app menu (`o`) with arrow-key selection (`↑`/`↓`, `Enter`, `Esc`)
- New opener support for `nano`, `less`, and `bat` for file actions
- Blocking terminal app execution with UI state restore after exit
### Improved
- Open-with options are now context-aware (GUI/headless, file/directory) and only shown when executables are available
- Better handling of paths containing spaces when launching openers
- Extended README with full controls reference and platform compatibility guidance
## [0.2.5] - 2026-04-03 ## [0.2.5] - 2026-04-03
### Improved ### Improved

View File

@@ -24,11 +24,62 @@ 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) - 📜 Navigation history inside the session (`h` history mode, `b` back, `f` forward)
- ⚡ Returns the selected path to the shell (`Enter` to confirm, `Esc` to cancel) - 🔎 Incremental filter mode (`/`, type to filter, `Backspace`, `Esc`)
- 🔎 Press `.` to toggle hidden directories - 📄 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`)
- 👀 Toggle hidden entries with `.`
- 🖥 Native terminal interface - 🖥 Native terminal interface
- 🔁 Works with Bash, Zsh and other shells - 🔁 Works with Bash, Zsh and other shells
- 🚀 Fast visual navigation of directory trees - ⚡ Returns the selected path to the shell (`Enter` to confirm, `Esc` to cancel)
## Controls
Normal mode:
- `↑` / `↓`: Move selection
- `→`: Expand directory, or enter when already expanded
- `←`: Collapse directory, or go to parent
- `Enter`: Confirm selected path and exit
- `Esc`: Cancel and return the original path
- `h`: Toggle history mode
- `b` / `f`: Back / forward in navigation history
- `.`: Toggle hidden entries
- `a`: Toggle file visibility
- `/`: Enter filter mode
- `o`: Open selected entry with an app
- `PgUp` / `PgDn`: Page jump
- `Home` / `End`: Jump to first/last entry
- `g` / `G`: Jump to first/last entry
Filter mode:
- Type text: Filter visible entries in real time (case-insensitive)
- `↑` / `↓`: Move inside filtered results
- `Enter`: Apply filter and perform contextual action:
- Directory: navigate into it without exiting `cd_`
- File: open app selection menu
- `Esc`: Exit filter mode and clear query
- `Backspace`: Remove last filter character
History mode:
- `↑` / `↓`: Move through history entries
- `Enter`: Jump to selected history path
- `Esc` or `h`: Exit history mode
- `PgUp` / `PgDn`, `Home` / `End`: Fast history navigation
Open with menu (`o`):
- `↑` / `↓`: Move between app options
- `Enter`: Launch selected app
- `Esc`: Cancel
The menu only shows apps available in your system (`PATH`) and context:
- GUI options (when GUI is available): `code`, system `open`/`xdg-open`
- Terminal options: `antigravity`, `nvim`, `nano`, `less`, `bat` (as applicable)
## Quick Demo ## Quick Demo
@@ -99,7 +150,14 @@ Then use:
cd_ cd_
``` ```
- Dentro de `cd_browser`, presiona `.` para alternar la visualización de carpetas ocultas. - Inside `cd_browser`, press `.` to toggle hidden entries.
## Platform Compatibility
Practical conclusion:
- Best current support: **macOS and Linux**
- **Windows is partially supported**, but still needs real-world validation in Windows terminal environments before being considered fully supported
## Uninstall ## Uninstall

View File

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

View File

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

View File

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

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

@@ -0,0 +1,189 @@
from __future__ import annotations
import os
import shlex
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
LaunchMode = Literal["background", "blocking"]
@dataclass(frozen=True, slots=True)
class OpenWithOption:
"""Candidate application command for opening a path."""
label: str
command_template: str
executable: str
launch_mode: LaunchMode
requires_gui: bool = False
supports_files: bool = True
supports_directories: bool = True
def available_openers(path: Path) -> list[OpenWithOption]:
"""Return available open-with options for a file or directory path."""
is_directory = path.is_dir()
has_gui = _has_gui_session()
options: list[OpenWithOption] = []
for option in _default_options():
if is_directory and not option.supports_directories:
continue
if not is_directory and not option.supports_files:
continue
if option.requires_gui and not has_gui:
continue
if not shutil.which(option.executable):
continue
options.append(option)
return options
def launch_background_option(path: Path, option: OpenWithOption) -> tuple[bool, str]:
"""Launch a non-blocking option and return status/message."""
command = _command_parts(option.command_template, path)
if not command:
return False, "Invalid open command"
try:
subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except OSError as error:
return False, f"Open failed: {error}"
return True, f"Opened with {option.label}"
def launch_blocking_option(path: Path, option: OpenWithOption) -> tuple[bool, str]:
"""Launch a blocking terminal option and return status/message."""
command = _command_parts(option.command_template, path)
if not command:
return False, "Invalid open command"
try:
result = subprocess.run(command, check=False)
except OSError as error:
return False, f"Open failed: {error}"
if result.returncode != 0:
return (
False,
f"Open failed: {option.label} exited with code {result.returncode}",
)
return True, f"Opened with {option.label}"
def is_blocking_option(option: OpenWithOption) -> bool:
"""Return whether the option should block the current terminal session."""
return option.launch_mode == "blocking"
def _default_options() -> tuple[OpenWithOption, ...]:
return (
OpenWithOption(
label="VS Code",
command_template="code {path}",
executable="code",
launch_mode="background",
requires_gui=True,
),
OpenWithOption(
label="Open (system)",
command_template=_system_open_template(),
executable=_system_open_executable(),
launch_mode="background",
requires_gui=True,
supports_directories=False,
),
OpenWithOption(
label="Antigravity",
command_template="antigravity {path}",
executable="antigravity",
launch_mode="background",
),
OpenWithOption(
label="Neovim",
command_template="nvim {path}",
executable="nvim",
launch_mode="blocking",
),
OpenWithOption(
label="Nano",
command_template="nano {path}",
executable="nano",
launch_mode="blocking",
supports_directories=False,
),
OpenWithOption(
label="Less",
command_template="less {path}",
executable="less",
launch_mode="blocking",
supports_directories=False,
),
OpenWithOption(
label="Bat",
command_template="bat {path}",
executable="bat",
launch_mode="blocking",
supports_directories=False,
),
)
def _has_gui_session() -> bool:
if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY"):
return False
if sys.platform == "darwin":
return True
if sys.platform.startswith("linux"):
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
if sys.platform.startswith("win"):
return True
return False
def _system_open_template() -> str:
if sys.platform == "darwin":
return "open {path}"
if sys.platform.startswith("linux"):
return "xdg-open {path}"
if sys.platform.startswith("win"):
return 'cmd /c start "" {path}'
return "open {path}"
def _system_open_executable() -> str:
if sys.platform == "darwin":
return "open"
if sys.platform.startswith("linux"):
return "xdg-open"
if sys.platform.startswith("win"):
return "cmd"
return "open"
def _command_parts(command_template: str, path: Path) -> list[str]:
rendered = command_template.format(path=shlex.quote(str(path)))
return shlex.split(rendered)

View File

@@ -10,8 +10,16 @@ from pathlib import Path
from typing import TextIO from typing import TextIO
from app.navigator import Navigator, VisibleEntry from app.navigator import Navigator, VisibleEntry
from app.opener import (
OpenWithOption,
available_openers,
is_blocking_option,
launch_background_option,
launch_blocking_option,
)
ESCAPE_KEY = 27 ESCAPE_KEY = 27
BACKSPACE_KEYS = (curses.KEY_BACKSPACE, 127, 8)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -46,9 +54,13 @@ def format_entry(entry: VisibleEntry) -> str:
return entry.name return entry.name
indent = " " * entry.depth indent = " " * entry.depth
indicator = " "
if entry.has_children: if entry.is_file:
indicator = ""
elif entry.has_children:
indicator = "" if entry.is_expanded else "" indicator = "" if entry.is_expanded else ""
else:
indicator = " "
return f"{indent}{indicator} {entry.name}" return f"{indent}{indicator} {entry.name}"
@@ -104,6 +116,11 @@ class TerminalUI:
self._history_selected_index = 0 self._history_selected_index = 0
self._tree_scroll_offset = 0 self._tree_scroll_offset = 0
self._history_scroll_offset = 0 self._history_scroll_offset = 0
self._filter_mode = False
self._filter_query = ""
self._filter_selected_index = 0
self._filter_scroll_offset = 0
self._status_message: str | None = None
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."""
@@ -121,6 +138,16 @@ class TerminalUI:
self._render(stdscr, navigator) self._render(stdscr, navigator)
key = stdscr.getch() key = stdscr.getch()
if self._history_mode:
self._handle_history_key(key, navigator, stdscr=stdscr)
continue
if self._filter_mode:
filter_result = self._handle_filter_key(key, navigator, stdscr)
if filter_result is not None:
return filter_result
continue
if key == ESCAPE_KEY: if key == ESCAPE_KEY:
return original_path return original_path
@@ -128,11 +155,7 @@ class TerminalUI:
self._toggle_history_mode(navigator) self._toggle_history_mode(navigator)
continue continue
if self._history_mode: tree_result = self._handle_tree_key(key, navigator, stdscr=stdscr)
self._handle_history_key(key, navigator)
continue
tree_result = self._handle_tree_key(key, navigator)
if tree_result is not None: if tree_result is not None:
return tree_result return tree_result
@@ -146,30 +169,92 @@ class TerminalUI:
return return
hidden_status = "on" if navigator.show_hidden else "off" hidden_status = "on" if navigator.show_hidden else "off"
path_text = f"{navigator.current_path} [hidden: {hidden_status}]" file_status = "on" if navigator.show_files else "off"
stdscr.addnstr(0, 0, path_text, width - 1) path_text = (
f"{navigator.current_path} [hidden: {hidden_status}] [files: {file_status}]"
lines = build_render_lines(navigator)
visible_count = max(0, height - 2)
self._tree_scroll_offset = clamp_scroll_offset(
navigator.selected_index,
self._tree_scroll_offset,
visible_count,
len(lines),
) )
stdscr.addnstr(0, 0, path_text, max(0, width - 1))
for row, line in enumerate( start_row = 2
lines[self._tree_scroll_offset : self._tree_scroll_offset + visible_count], if self._filter_mode:
start=2, stdscr.addnstr(
): 1,
if row >= height: 0,
break f"Filter: {self._filter_query}",
max(0, width - 1),
curses.A_BOLD,
)
start_row = 3
attributes = curses.A_REVERSE if line.is_selected else curses.A_NORMAL lines, selected_index = self._build_tree_lines_for_render(navigator)
stdscr.addnstr(row, 0, line.text, width - 1, attributes) footer_rows = 1 if self._status_message else 0
visible_count = max(0, height - start_row - footer_rows)
if self._filter_mode:
self._filter_scroll_offset = clamp_scroll_offset(
selected_index,
self._filter_scroll_offset,
visible_count,
len(lines),
)
scroll_offset = self._filter_scroll_offset
else:
self._tree_scroll_offset = clamp_scroll_offset(
selected_index,
self._tree_scroll_offset,
visible_count,
len(lines),
)
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)
else:
for row, line in enumerate(
lines[scroll_offset : scroll_offset + visible_count],
start=start_row,
):
if row >= height:
break
attributes = curses.A_REVERSE if line.is_selected else curses.A_NORMAL
stdscr.addnstr(row, 0, line.text, max(0, width - 1), attributes)
if self._status_message and height > 0:
stdscr.addnstr(
height - 1,
0,
self._status_message,
max(0, width - 1),
curses.A_DIM,
)
stdscr.refresh() stdscr.refresh()
def _build_tree_lines_for_render(
self, navigator: Navigator
) -> tuple[list[RenderLine], int]:
if not self._filter_mode:
return build_render_lines(navigator), navigator.selected_index
entries = navigator.visible_entries
matches = self._matching_indices(entries)
if not matches:
self._filter_selected_index = 0
return [], 0
self._filter_selected_index = max(
0, min(self._filter_selected_index, len(matches) - 1)
)
return [
RenderLine(
text=format_entry(entries[entry_index]),
is_selected=match_index == self._filter_selected_index,
)
for match_index, entry_index in enumerate(matches)
], 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, width: int, height: int
) -> None: ) -> None:
@@ -177,7 +262,8 @@ class TerminalUI:
navigator.history.entries(), self._history_selected_index navigator.history.entries(), self._history_selected_index
) )
start_row = 1 start_row = 1
visible_count = max(0, height - start_row - 1) 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_scroll_offset = clamp_scroll_offset(
self._history_selected_index, self._history_selected_index,
self._history_scroll_offset, self._history_scroll_offset,
@@ -185,7 +271,7 @@ class TerminalUI:
len(history_lines), len(history_lines),
) )
stdscr.addnstr(start_row, 0, "History:", width - 1, curses.A_BOLD) stdscr.addnstr(start_row, 0, "History:", max(0, width - 1), curses.A_BOLD)
for index, line in enumerate( for index, line in enumerate(
history_lines[ history_lines[
@@ -199,17 +285,33 @@ class TerminalUI:
break break
attributes = curses.A_REVERSE if line.is_selected else curses.A_NORMAL attributes = curses.A_REVERSE if line.is_selected else curses.A_NORMAL
stdscr.addnstr(row, 0, line.text, width - 1, attributes) stdscr.addnstr(row, 0, line.text, max(0, width - 1), attributes)
if self._status_message and height > 0:
stdscr.addnstr(
height - 1,
0,
self._status_message,
max(0, width - 1),
curses.A_DIM,
)
def _toggle_history_mode(self, navigator: Navigator) -> None: def _toggle_history_mode(self, navigator: Navigator) -> None:
self._history_mode = not self._history_mode self._history_mode = not self._history_mode
self._status_message = None
if self._history_mode: if self._history_mode:
self._history_selected_index = self._get_current_history_index(navigator) self._history_selected_index = self._get_current_history_index(navigator)
self._history_scroll_offset = 0 self._history_scroll_offset = 0
def _handle_history_key(self, key: int, navigator: Navigator) -> None: def _handle_history_key(
self, key: int, navigator: Navigator, stdscr: curses.window | None = None
) -> None:
history_entries = navigator.history.entries() history_entries = navigator.history.entries()
if key in (ESCAPE_KEY, ord("h")):
self._history_mode = False
return
if key == curses.KEY_UP: if key == curses.KEY_UP:
self._history_selected_index = move_selection( self._history_selected_index = move_selection(
self._history_selected_index, self._history_selected_index,
@@ -226,12 +328,134 @@ class TerminalUI:
) )
return return
if key == curses.KEY_PPAGE:
step = -self._page_step(stdscr, start_row=1)
self._history_selected_index = move_selection(
self._history_selected_index,
step=step,
entry_count=len(history_entries),
)
return
if key == curses.KEY_NPAGE:
step = self._page_step(stdscr, start_row=1)
self._history_selected_index = move_selection(
self._history_selected_index,
step=step,
entry_count=len(history_entries),
)
return
if key in (curses.KEY_HOME, ord("g")):
self._history_selected_index = 0
return
if key in (curses.KEY_END, ord("G")) and history_entries:
self._history_selected_index = len(history_entries) - 1
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) navigator.select_history_entry(self._history_selected_index)
self._history_mode = False self._history_mode = False
self._tree_scroll_offset = 0 self._tree_scroll_offset = 0
def _handle_tree_key(self, key: int, navigator: Navigator) -> Path | None: def _handle_filter_key(
self, key: int, navigator: Navigator, stdscr: curses.window | None = None
) -> Path | None:
entries = navigator.visible_entries
matches = self._matching_indices(entries)
if key == ESCAPE_KEY:
self._filter_mode = False
self._filter_query = ""
self._filter_selected_index = 0
self._filter_scroll_offset = 0
return None
if key in (curses.KEY_ENTER, 10, 13):
if not matches:
return None
selected_entry_index = matches[self._filter_selected_index]
navigator.set_selected_index(selected_entry_index)
selected = entries[selected_entry_index]
self._filter_mode = False
self._filter_query = ""
self._filter_selected_index = 0
self._filter_scroll_offset = 0
if selected.is_file:
self._open_with_selected(navigator, stdscr)
return None
navigator.enter_selected_directory()
self._tree_scroll_offset = 0
return None
if key == curses.KEY_UP:
self._filter_selected_index = move_selection(
self._filter_selected_index,
step=-1,
entry_count=len(matches),
)
return None
if key == curses.KEY_DOWN:
self._filter_selected_index = move_selection(
self._filter_selected_index,
step=1,
entry_count=len(matches),
)
return None
if key == curses.KEY_PPAGE:
step = -self._page_step(stdscr, start_row=3)
self._filter_selected_index = move_selection(
self._filter_selected_index,
step=step,
entry_count=len(matches),
)
return None
if key == curses.KEY_NPAGE:
step = self._page_step(stdscr, start_row=3)
self._filter_selected_index = move_selection(
self._filter_selected_index,
step=step,
entry_count=len(matches),
)
return None
if key == curses.KEY_HOME:
self._filter_selected_index = 0
return None
if key == curses.KEY_END and matches:
self._filter_selected_index = len(matches) - 1
return None
if key in BACKSPACE_KEYS:
self._filter_query = self._filter_query[:-1]
self._filter_selected_index = 0
self._filter_scroll_offset = 0
return None
if 32 <= key <= 126:
self._filter_query += chr(key)
self._filter_selected_index = 0
self._filter_scroll_offset = 0
return None
def _handle_tree_key(
self,
key: int,
navigator: Navigator,
stdscr: curses.window | None = None,
) -> Path | None:
self._status_message = None
if key == curses.KEY_UP: if key == curses.KEY_UP:
navigator.set_selected_index( navigator.set_selected_index(
move_selection( move_selection(
@@ -252,8 +476,40 @@ class TerminalUI:
) )
return None return None
if key == curses.KEY_PPAGE:
navigator.set_selected_index(
move_selection(
navigator.selected_index,
step=-self._page_step(stdscr, start_row=2),
entry_count=len(navigator.visible_entries),
)
)
return None
if key == curses.KEY_NPAGE:
navigator.set_selected_index(
move_selection(
navigator.selected_index,
step=self._page_step(stdscr, start_row=2),
entry_count=len(navigator.visible_entries),
)
)
return None
if key in (curses.KEY_HOME, ord("g")):
navigator.set_selected_index(0)
return None
if key in (curses.KEY_END, ord("G")):
navigator.set_selected_index(len(navigator.visible_entries) - 1)
return None
if key == curses.KEY_RIGHT: if key == curses.KEY_RIGHT:
was_expanded = navigator.selected_entry.is_expanded selected = navigator.selected_entry
if selected.is_file:
return None
was_expanded = selected.is_expanded
if not navigator.expand_selected_directory(): if not navigator.expand_selected_directory():
navigator.enter_selected_directory() navigator.enter_selected_directory()
@@ -288,11 +544,167 @@ class TerminalUI:
self._tree_scroll_offset = 0 self._tree_scroll_offset = 0
return None return None
if key == ord("a"):
navigator.toggle_files()
self._tree_scroll_offset = 0
return None
if key == ord("/"):
self._filter_mode = True
self._filter_query = ""
self._filter_selected_index = 0
self._filter_scroll_offset = 0
return None
if key == ord("o"):
self._open_with_selected(navigator, stdscr)
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 _matching_indices(self, entries: tuple[VisibleEntry, ...]) -> list[int]:
if not self._filter_query:
return list(range(len(entries)))
query = self._filter_query.casefold()
return [
index
for index, entry in enumerate(entries)
if query in entry.name.casefold()
]
def _page_step(self, stdscr: curses.window | None, start_row: int) -> int:
if stdscr is None:
return 10
height, _width = stdscr.getmaxyx()
footer_rows = 1 if self._status_message else 0
return max(1, height - start_row - footer_rows)
def _open_with_selected(
self, navigator: Navigator, stdscr: curses.window | None
) -> None:
selected = navigator.selected_entry
options = available_openers(selected.path)
if not options:
self._status_message = "No applications available for this entry"
return
option_index = 0
if stdscr is not None:
choice = self._prompt_open_with_choice(stdscr, options, selected.name)
if choice is None:
self._status_message = "Open cancelled"
return
option_index = choice
option = options[option_index]
if is_blocking_option(option):
success, message = self._launch_blocking_with_terminal_restore(
stdscr, selected.path, option
)
else:
success, message = launch_background_option(selected.path, option)
self._status_message = message
if not success:
self._status_message = message
def _prompt_open_with_choice(
self,
stdscr: curses.window,
options: list[OpenWithOption],
entry_name: str,
) -> int | None:
selected_index = 0
while True:
height, width = stdscr.getmaxyx()
title_row = max(0, height - len(options) - 2)
title = f"Open '{entry_name}' with (↑/↓, Enter, Esc):"
stdscr.addnstr(
title_row,
0,
" " * max(0, width - 1),
max(0, width - 1),
)
stdscr.addnstr(
title_row,
0,
title,
max(0, width - 1),
curses.A_BOLD,
)
for index, option in enumerate(options):
row = title_row + index + 1
if row >= height:
break
attributes = (
curses.A_REVERSE if index == selected_index else curses.A_NORMAL
)
label = f" {option.label}"
stdscr.addnstr(
row,
0,
" " * max(0, width - 1),
max(0, width - 1),
)
stdscr.addnstr(row, 0, label, max(0, width - 1), attributes)
stdscr.refresh()
key = stdscr.getch()
if key == ESCAPE_KEY:
return None
if key == curses.KEY_UP:
selected_index = move_selection(
selected_index,
step=-1,
entry_count=len(options),
)
continue
if key == curses.KEY_DOWN:
selected_index = move_selection(
selected_index,
step=1,
entry_count=len(options),
)
continue
if key in (curses.KEY_ENTER, 10, 13):
return selected_index
def _launch_blocking_with_terminal_restore(
self,
stdscr: curses.window | None,
path: Path,
option: OpenWithOption,
) -> tuple[bool, str]:
if stdscr is None:
return launch_blocking_option(path, option)
curses.def_prog_mode()
curses.endwin()
try:
success, message = launch_blocking_option(path, option)
finally:
curses.reset_prog_mode()
stdscr.keypad(True)
stdscr.clear()
stdscr.refresh()
return success, message
def _get_current_history_index(self, navigator: Navigator) -> int: def _get_current_history_index(self, navigator: Navigator) -> int:
history_entries = navigator.history.entries() history_entries = navigator.history.entries()

View File

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

View File

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

147
tests/test_opener.py Normal file
View File

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

View File

@@ -52,6 +52,19 @@ def test_format_entry_supports_parent_and_tree_indicators(tmp_path: Path) -> Non
assert format_entry(expanded_child) == " build" assert format_entry(expanded_child) == " build"
def test_format_entry_marks_files_with_file_indicator(tmp_path: Path) -> None:
file_entry = VisibleEntry(
name="notes.txt",
path=tmp_path / "notes.txt",
depth=0,
has_children=False,
is_expanded=False,
is_file=True,
)
assert format_entry(file_entry) == "• notes.txt"
def test_build_render_lines_marks_selected_entry(tmp_path: Path) -> None: def test_build_render_lines_marks_selected_entry(tmp_path: Path) -> None:
workspace = tmp_path / "workspace" workspace = tmp_path / "workspace"
workspace.mkdir() workspace.mkdir()
@@ -221,6 +234,112 @@ def test_enter_returns_selected_path_without_navigating(tmp_path: Path) -> None:
assert navigator.current_path == workspace.resolve() assert navigator.current_path == workspace.resolve()
def test_toggle_files_key_updates_navigator_state(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
(root / "folder").mkdir()
(root / "notes.txt").write_text("content", encoding="utf-8")
navigator = Navigator(root)
ui = TerminalUI()
assert navigator.show_files is False
assert ui._handle_tree_key(ord("a"), navigator) is None
assert navigator.show_files is True
def test_filter_matching_is_case_insensitive(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
(root / "Alpha").mkdir()
(root / "beta").mkdir()
navigator = Navigator(root)
ui = TerminalUI()
ui._filter_mode = True
ui._filter_query = "alp"
matches = ui._matching_indices(navigator.visible_entries)
assert matches == [1]
def test_filter_mode_uses_g_and_g_upper_as_query_characters(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
(root / "alpha").mkdir()
(root / "gamma").mkdir()
navigator = Navigator(root)
ui = TerminalUI()
ui._filter_mode = True
assert ui._handle_filter_key(ord("g"), navigator) is None
assert ui._handle_filter_key(ord("G"), navigator) is None
assert ui._filter_query == "gG"
def test_filter_enter_on_directory_navigates_and_exits_filter(tmp_path: Path) -> None:
root = tmp_path / "root"
child = root / "child"
root.mkdir()
child.mkdir()
navigator = Navigator(root)
ui = TerminalUI()
ui._filter_mode = True
ui._filter_query = "child"
result = ui._handle_filter_key(10, navigator)
assert result is None
assert ui._filter_mode is False
assert navigator.current_path == child.resolve()
def test_filter_enter_on_file_opens_menu_and_exits_filter(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
root = tmp_path / "root"
root.mkdir()
file_path = root / "notes.txt"
file_path.write_text("hello", encoding="utf-8")
navigator = Navigator(root)
navigator.toggle_files()
ui = TerminalUI()
ui._filter_mode = True
ui._filter_query = "notes"
opened: dict[str, bool] = {"called": False}
def fake_open_with_selected(
_navigator: Navigator, _stdscr: curses.window | None
) -> None:
opened["called"] = True
monkeypatch.setattr(ui, "_open_with_selected", fake_open_with_selected)
result = ui._handle_filter_key(10, navigator)
assert result is None
assert ui._filter_mode is False
assert opened["called"] is True
assert navigator.selected_entry.path == file_path.resolve()
def test_end_key_jumps_to_last_entry(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
for name in ("alpha", "beta", "gamma"):
(root / name).mkdir()
navigator = Navigator(root)
ui = TerminalUI()
assert ui._handle_tree_key(curses.KEY_END, navigator) is None
assert navigator.selected_index == len(navigator.visible_entries) - 1
def test_open_terminal_streams_raises_without_terminal( def test_open_terminal_streams_raises_without_terminal(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None: