Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f78f6d9ca |
16
CHANGELOG.md
16
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
|
||||
|
||||
66
README.md
66
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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
189
src/app/opener.py
Normal file
189
src/app/opener.py
Normal 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)
|
||||
454
src/app/ui.py
454
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)
|
||||
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))
|
||||
|
||||
lines = build_render_lines(navigator)
|
||||
visible_count = max(0, height - 2)
|
||||
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
|
||||
|
||||
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(
|
||||
navigator.selected_index,
|
||||
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[self._tree_scroll_offset : self._tree_scroll_offset + visible_count],
|
||||
start=2,
|
||||
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, 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()
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
147
tests/test_opener.py
Normal file
147
tests/test_opener.py
Normal 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)]
|
||||
119
tests/test_ui.py
119
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:
|
||||
|
||||
Reference in New Issue
Block a user