From 6f78f6d9ca0d9ac163e551365f036c582471c2cd Mon Sep 17 00:00:00 2001 From: Saky Date: Sat, 4 Apr 2026 01:53:00 +0200 Subject: [PATCH] release: 0.2.6 add advanced navigation and contextual open actions --- CHANGELOG.md | 16 ++ README.md | 66 +++++- pyproject.toml | 2 +- src/app/filesystem.py | 44 +++- src/app/navigator.py | 35 ++- src/app/opener.py | 189 ++++++++++++++++ src/app/ui.py | 474 ++++++++++++++++++++++++++++++++++++--- tests/test_filesystem.py | 30 ++- tests/test_navigator.py | 18 ++ tests/test_opener.py | 147 ++++++++++++ tests/test_ui.py | 119 ++++++++++ 11 files changed, 1090 insertions(+), 50 deletions(-) create mode 100644 src/app/opener.py create mode 100644 tests/test_opener.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b853e29..2acaa5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. +## [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 diff --git a/README.md b/README.md index e538961..e7bfd4c 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,62 @@ 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) -- ⚡ Returns the selected path to the shell (`Enter` to confirm, `Esc` to cancel) -- 🔎 Press `.` to toggle hidden directories +- 🔎 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 - 🔁 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 @@ -99,7 +150,14 @@ Then use: 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 diff --git a/pyproject.toml b/pyproject.toml index bea9776..11a211f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "cd-browser" -version = "0.2.5" +version = "0.2.6" description = "A fast keyboard-driven directory navigator for the terminal." readme = "README.md" requires-python = ">=3.8" diff --git a/src/app/filesystem.py b/src/app/filesystem.py index b3d72e2..de62ae1 100644 --- a/src/app/filesystem.py +++ b/src/app/filesystem.py @@ -13,11 +13,37 @@ class DirectoryEntry: 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]: """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() - entries: list[DirectoryEntry] = [] + entries: list[FileSystemEntry] = [] try: children = list(directory_path.iterdir()) @@ -30,20 +56,28 @@ def list_directories(path: Path, show_hidden: bool = False) -> list[DirectoryEnt except OSError: continue - if not is_directory: + if not is_directory and not include_files: continue if not show_hidden and child.name.startswith("."): continue entries.append( - DirectoryEntry( + FileSystemEntry( name=child.name, 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: diff --git a/src/app/navigator.py b/src/app/navigator.py index 5adbcc8..fda6e22 100644 --- a/src/app/navigator.py +++ b/src/app/navigator.py @@ -3,13 +3,13 @@ from __future__ import annotations from dataclasses import dataclass, field from pathlib import Path -from app.filesystem import list_directories +from app.filesystem import list_entries from app.history import SessionHistory @dataclass(frozen=True, slots=True) class VisibleEntry: - """A directory row visible in the navigator.""" + """A row visible in the navigator tree.""" name: str path: Path @@ -17,6 +17,7 @@ class VisibleEntry: has_children: bool is_expanded: bool is_parent: bool = False + is_file: bool = False @dataclass(slots=True) @@ -29,6 +30,7 @@ class Navigator: selected_index: int = field(init=False, default=0) _expanded_paths: set[Path] = field(init=False, default_factory=set) show_hidden: bool = False + show_files: bool = False def __post_init__(self) -> None: self.current_path = self.start_path.expanduser().resolve() @@ -69,6 +71,9 @@ class Navigator: def enter_selected_directory(self) -> Path: """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) def go_to_parent_directory(self) -> Path: @@ -149,6 +154,15 @@ class Navigator: """Toggle showing/hiding hidden directory entries.""" 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._expanded_paths.clear() @@ -162,20 +176,25 @@ class Navigator: def _build_entries(self, path: Path, depth: int) -> list[VisibleEntry]: entries: list[VisibleEntry] = [] - for directory in list_directories(path, show_hidden=self.show_hidden): - is_expanded = directory.path in self._expanded_paths + for item in list_entries( + 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( VisibleEntry( - name=directory.name, - path=directory.path, + name=item.name, + path=item.path, depth=depth, - has_children=directory.has_children, + has_children=item.has_children, is_expanded=is_expanded, + is_file=not item.is_directory, ) ) 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 diff --git a/src/app/opener.py b/src/app/opener.py new file mode 100644 index 0000000..7a4d889 --- /dev/null +++ b/src/app/opener.py @@ -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) diff --git a/src/app/ui.py b/src/app/ui.py index b6312fc..2d1917a 100644 --- a/src/app/ui.py +++ b/src/app/ui.py @@ -10,8 +10,16 @@ from pathlib import Path from typing import TextIO 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 +BACKSPACE_KEYS = (curses.KEY_BACKSPACE, 127, 8) @dataclass(frozen=True, slots=True) @@ -46,9 +54,13 @@ def format_entry(entry: VisibleEntry) -> str: return entry.name indent = " " * entry.depth - indicator = " " - if entry.has_children: + + if entry.is_file: + indicator = "•" + elif entry.has_children: indicator = "▼" if entry.is_expanded else "▶" + else: + indicator = " " return f"{indent}{indicator} {entry.name}" @@ -104,6 +116,11 @@ class TerminalUI: self._history_selected_index = 0 self._tree_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: """Start the interactive session and return the final directory path.""" @@ -121,6 +138,16 @@ class TerminalUI: self._render(stdscr, navigator) 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: return original_path @@ -128,11 +155,7 @@ class TerminalUI: self._toggle_history_mode(navigator) continue - if self._history_mode: - self._handle_history_key(key, navigator) - continue - - tree_result = self._handle_tree_key(key, navigator) + tree_result = self._handle_tree_key(key, navigator, stdscr=stdscr) if tree_result is not None: return tree_result @@ -146,30 +169,92 @@ class TerminalUI: return hidden_status = "on" if navigator.show_hidden else "off" - path_text = f"{navigator.current_path} [hidden: {hidden_status}]" - stdscr.addnstr(0, 0, path_text, width - 1) - - 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), + 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)) - for row, line in enumerate( - lines[self._tree_scroll_offset : self._tree_scroll_offset + visible_count], - start=2, - ): - if row >= height: - break + start_row = 2 + if self._filter_mode: + stdscr.addnstr( + 1, + 0, + 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 - stdscr.addnstr(row, 0, line.text, width - 1, attributes) + 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( + 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() + 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( self, stdscr: curses.window, navigator: Navigator, width: int, height: int ) -> None: @@ -177,7 +262,8 @@ class TerminalUI: navigator.history.entries(), self._history_selected_index ) 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_selected_index, self._history_scroll_offset, @@ -185,7 +271,7 @@ class TerminalUI: 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( history_lines[ @@ -199,17 +285,33 @@ class TerminalUI: break 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: self._history_mode = not self._history_mode + self._status_message = None if self._history_mode: self._history_selected_index = self._get_current_history_index(navigator) 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() + if key in (ESCAPE_KEY, ord("h")): + self._history_mode = False + return + if key == curses.KEY_UP: self._history_selected_index = move_selection( self._history_selected_index, @@ -226,12 +328,134 @@ class TerminalUI: ) 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: navigator.select_history_entry(self._history_selected_index) self._history_mode = False 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: navigator.set_selected_index( move_selection( @@ -252,8 +476,40 @@ class TerminalUI: ) 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: - 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(): navigator.enter_selected_directory() @@ -288,11 +544,167 @@ class TerminalUI: self._tree_scroll_offset = 0 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): return navigator.selected_entry.path 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: history_entries = navigator.history.entries() diff --git a/tests/test_filesystem.py b/tests/test_filesystem.py index eea9891..653a8c7 100644 --- a/tests/test_filesystem.py +++ b/tests/test_filesystem.py @@ -2,7 +2,13 @@ from pathlib import Path 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: @@ -46,6 +52,28 @@ def test_list_directories_raises_for_missing_directory(tmp_path: Path) -> None: 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: root = tmp_path / "root" root.mkdir() diff --git a/tests/test_navigator.py b/tests/test_navigator.py index 5d48efa..dc4c5ab 100644 --- a/tests/test_navigator.py +++ b/tests/test_navigator.py @@ -50,6 +50,24 @@ def test_toggle_hidden_directories(tmp_path: Path) -> None: 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: current = tmp_path / "workspace" current.mkdir() diff --git a/tests/test_opener.py b/tests/test_opener.py new file mode 100644 index 0000000..e2285f4 --- /dev/null +++ b/tests/test_opener.py @@ -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)] diff --git a/tests/test_ui.py b/tests/test_ui.py index a43edca..e003963 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -52,6 +52,19 @@ def test_format_entry_supports_parent_and_tree_indicators(tmp_path: Path) -> Non 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: workspace = tmp_path / "workspace" workspace.mkdir() @@ -221,6 +234,112 @@ def test_enter_returns_selected_path_without_navigating(tmp_path: Path) -> None: 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( monkeypatch: pytest.MonkeyPatch, ) -> None: