5 Commits

12 changed files with 1112 additions and 54 deletions

View File

@@ -4,6 +4,38 @@ 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
### Improved
- Add explicit repository link in README for PyPI users to access the full demo and documentation
## [0.2.4] - 2026-04-03
### Fixed
- Use absolute demo GIF URL in README so image renders correctly on PyPI
## [0.2.3] - 2026-04-02
### Added
- Add project demo GIF at `docs/demo.gif`
- Show GIF demo in README
## [0.2.2] - 2026-04-02 ## [0.2.2] - 2026-04-02
### Fixed ### Fixed

View File

@@ -5,7 +5,9 @@
`cd-browser` is a fast keyboard-driven directory navigator for the terminal. `cd-browser` is a fast keyboard-driven directory navigator for the terminal.
It lets you explore directory trees visually and jump to any folder instantly. It lets you explore directory trees visually and jump to any folder instantly.
![cd-browser demo](docs/demo.gif) ![cd-browser demo](https://gitea.sakydogalo.es/saky/cd-browser/raw/branch/main/docs/demo.gif)
Repository (full demo + docs): https://gitea.sakydogalo.es/saky/cd-browser
## Why cd-browser? ## Why cd-browser?
@@ -19,14 +21,65 @@ Working in the terminal often means:
## Features ## Features
- 🚀 Fast visual navigation of directory trees - ⌨️ Fully keyboard-driven workflow (`↑` / `↓` / `←` / `→`)
- ⌨️ Fully keyboard-driven workflow - 🌲 Expand and collapse directories (`→` to expand/enter, `←` to collapse/back)
- 🌲 Expand and collapse directories - 📜 Navigation history inside the session (`h` history mode, `b` back, `f` forward)
- 📜 Navigation history inside the session - 🔎 Incremental filter mode (`/`, type to filter, `Backspace`, `Esc`)
- 📄 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
- ⚡ Returns the selected path to the shell - ⚡ Returns the selected path to the shell (`Enter` to confirm, `Esc` to cancel)
- 🔎 Press `.` to toggle hidden directories
## 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
@@ -97,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

BIN
docs/demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "cd-browser" name = "cd-browser"
version = "0.2.2" 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}]"
)
stdscr.addnstr(0, 0, path_text, max(0, width - 1))
lines = build_render_lines(navigator) start_row = 2
visible_count = max(0, height - 2) if self._filter_mode:
stdscr.addnstr(
1,
0,
f"Filter: {self._filter_query}",
max(0, width - 1),
curses.A_BOLD,
)
start_row = 3
lines, selected_index = self._build_tree_lines_for_render(navigator)
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( self._tree_scroll_offset = clamp_scroll_offset(
navigator.selected_index, selected_index,
self._tree_scroll_offset, self._tree_scroll_offset,
visible_count, visible_count,
len(lines), 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( for row, line in enumerate(
lines[self._tree_scroll_offset : self._tree_scroll_offset + visible_count], lines[scroll_offset : scroll_offset + visible_count],
start=2, start=start_row,
): ):
if row >= height: if row >= height:
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,
)
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: