Compare commits
2 Commits
v0.1.1
...
ab689cfbb4
| Author | SHA1 | Date | |
|---|---|---|---|
| ab689cfbb4 | |||
| 9688ed0974 |
29
CHANGELOG.md
29
CHANGELOG.md
@@ -4,16 +4,23 @@ 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.
|
||||||
|
|
||||||
## [1.0.0] - 2026-03-14
|
## [0.1.1] - 2026-03-16
|
||||||
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Initial release of the Python AI Dev Template
|
- AI logging system
|
||||||
- Universal Python project structure using `src/`
|
- AI agent workflow framework
|
||||||
- `pyproject.toml`-based project configuration
|
- ai-task tooling
|
||||||
- Development tooling with `black`, `ruff`, `mypy`, and `pytest`
|
|
||||||
- `pre-commit` integration
|
### Improved
|
||||||
- `Makefile` with development, quality, and diagnostic commands
|
- Terminal navigation
|
||||||
- `instructions-agent.md` for AI coding agent guidance
|
- History mode behavior
|
||||||
- `.env.example` for environment-based configuration
|
- UI scrolling
|
||||||
- README with setup, workflow, and template reuse instructions
|
|
||||||
- MIT license
|
## [0.1.0] - 2026-03-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial release of **cd-browser**
|
||||||
|
- Terminal-based directory navigation
|
||||||
|
- Interactive directory tree navigation
|
||||||
|
- History navigation mode
|
||||||
|
|||||||
50
README.md
50
README.md
@@ -1,24 +1,50 @@
|
|||||||
# cd-browser
|
# cd-browser
|
||||||
|
|
||||||
`cd-browser` is a terminal-native directory navigator for fast filesystem browsing from the command line.
|
**Stop typing paths. Browse them.**
|
||||||
|
|
||||||
It provides an interactive interface for exploring directories with the keyboard and returns the selected path at the end of the session so shell wrappers can change the current shell directory.
|
`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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Why cd-browser?
|
||||||
|
|
||||||
|
Working in the terminal often means:
|
||||||
|
|
||||||
|
- typing long directory paths
|
||||||
|
- navigating deep folder trees
|
||||||
|
- repeating `cd ..` multiple times
|
||||||
|
|
||||||
|
`cd-browser` provides an **interactive terminal UI** that allows you to browse directories with the keyboard and return the selected path directly to your shell.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🚀 Fast visual navigation of directory trees
|
||||||
|
- ⌨️ Fully keyboard-driven workflow
|
||||||
|
- 🌲 Expand and collapse directories
|
||||||
|
- 📜 Navigation history inside the session
|
||||||
|
- 🖥 Native terminal interface
|
||||||
|
- 🔁 Works with Bash, Zsh and other shells
|
||||||
|
- ⚡ Returns the selected path to the shell
|
||||||
|
- 🔎 Press `.` to toggle hidden directories
|
||||||
|
|
||||||
|
## Quick Demo
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd_
|
||||||
|
```
|
||||||
|
|
||||||
|
Browse directories using the arrow keys and press **Enter** to jump directly to the selected folder.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
See the documentation index:
|
See the documentation index:
|
||||||
|
|
||||||
|
```
|
||||||
docs/index.md
|
docs/index.md
|
||||||
|
```
|
||||||
## Features
|
|
||||||
|
|
||||||
- Interactive terminal directory browser
|
|
||||||
- Keyboard-driven navigation
|
|
||||||
- Parent directory entry via `..`
|
|
||||||
- Expand and collapse directory trees
|
|
||||||
- Session navigation history support in the application state
|
|
||||||
- Installable CLI command: `cd_browser`
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -77,6 +103,8 @@ Then use:
|
|||||||
cd_
|
cd_
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- Dentro de `cd_browser`, presiona `.` para alternar la visualización de carpetas ocultas.
|
||||||
|
|
||||||
## Uninstall
|
## Uninstall
|
||||||
|
|
||||||
If the project was installed with `pip`, remove it with:
|
If the project was installed with `pip`, remove it with:
|
||||||
|
|||||||
@@ -13,28 +13,41 @@ class DirectoryEntry:
|
|||||||
has_children: bool
|
has_children: bool
|
||||||
|
|
||||||
|
|
||||||
def list_directories(path: Path) -> 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."""
|
||||||
|
|
||||||
directory_path = path.expanduser().resolve()
|
directory_path = path.expanduser().resolve()
|
||||||
entries: list[DirectoryEntry] = []
|
entries: list[DirectoryEntry] = []
|
||||||
|
|
||||||
for child in directory_path.iterdir():
|
for child in directory_path.iterdir():
|
||||||
if child.is_dir():
|
if not child.is_dir():
|
||||||
|
continue
|
||||||
|
if not show_hidden and child.name.startswith("."):
|
||||||
|
continue
|
||||||
|
|
||||||
entries.append(
|
entries.append(
|
||||||
DirectoryEntry(
|
DirectoryEntry(
|
||||||
name=child.name,
|
name=child.name,
|
||||||
path=child,
|
path=child,
|
||||||
has_children=has_subdirectories(child),
|
has_children=has_subdirectories(child, show_hidden=show_hidden),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return sorted(entries, key=lambda entry: entry.name.casefold())
|
return sorted(entries, key=lambda entry: entry.name.casefold())
|
||||||
|
|
||||||
|
|
||||||
def has_subdirectories(path: Path) -> bool:
|
def has_subdirectories(path: Path, show_hidden: bool = False) -> bool:
|
||||||
"""Return whether the directory contains at least one subdirectory."""
|
"""Return whether the directory contains at least one subdirectory."""
|
||||||
|
|
||||||
directory_path = path.expanduser().resolve()
|
directory_path = path.expanduser().resolve()
|
||||||
|
|
||||||
return any(child.is_dir() for child in directory_path.iterdir())
|
for child in directory_path.iterdir():
|
||||||
|
if not child.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not show_hidden and child.name.startswith("."):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class Navigator:
|
|||||||
current_path: Path = field(init=False)
|
current_path: Path = field(init=False)
|
||||||
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
|
||||||
|
|
||||||
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()
|
||||||
@@ -144,6 +145,13 @@ class Navigator:
|
|||||||
self._set_current_path(path)
|
self._set_current_path(path)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
def toggle_hidden(self) -> None:
|
||||||
|
"""Toggle showing/hiding hidden directory entries."""
|
||||||
|
|
||||||
|
self.show_hidden = not self.show_hidden
|
||||||
|
self.selected_index = 0
|
||||||
|
self._expanded_paths.clear()
|
||||||
|
|
||||||
def select_history_entry(self, index: int) -> Path:
|
def select_history_entry(self, index: int) -> Path:
|
||||||
"""Jump to a directory stored in session history."""
|
"""Jump to a directory stored in session history."""
|
||||||
|
|
||||||
@@ -154,7 +162,7 @@ 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):
|
for directory in list_directories(path, show_hidden=self.show_hidden):
|
||||||
is_expanded = directory.path in self._expanded_paths
|
is_expanded = directory.path in self._expanded_paths
|
||||||
entries.append(
|
entries.append(
|
||||||
VisibleEntry(
|
VisibleEntry(
|
||||||
|
|||||||
@@ -145,7 +145,8 @@ class TerminalUI:
|
|||||||
stdscr.refresh()
|
stdscr.refresh()
|
||||||
return
|
return
|
||||||
|
|
||||||
path_text = str(navigator.current_path)
|
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)
|
stdscr.addnstr(0, 0, path_text, width - 1)
|
||||||
|
|
||||||
lines = build_render_lines(navigator)
|
lines = build_render_lines(navigator)
|
||||||
@@ -282,6 +283,11 @@ class TerminalUI:
|
|||||||
self._tree_scroll_offset = 0
|
self._tree_scroll_offset = 0
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if key == ord("."):
|
||||||
|
navigator.toggle_hidden()
|
||||||
|
self._tree_scroll_offset = 0
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import pytest
|
|||||||
|
|
||||||
from app.cli import run_cli
|
from app.cli import run_cli
|
||||||
from app.navigator import Navigator
|
from app.navigator import Navigator
|
||||||
|
from app.ui import TerminalUI
|
||||||
|
|
||||||
|
|
||||||
class StubUI:
|
class StubUI(TerminalUI):
|
||||||
def __init__(self, result: Path) -> None:
|
def __init__(self, result: Path) -> None:
|
||||||
self.result = result
|
self.result = result
|
||||||
self.navigator: Navigator | None = None
|
self.navigator: Navigator | None = None
|
||||||
|
|||||||
@@ -25,6 +25,31 @@ def test_navigator_initial_state_includes_parent_entry(tmp_path: Path) -> None:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_hidden_directories(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
root.mkdir()
|
||||||
|
visible = root / "visible"
|
||||||
|
visible.mkdir()
|
||||||
|
hidden = root / ".hidden"
|
||||||
|
hidden.mkdir()
|
||||||
|
|
||||||
|
navigator = Navigator(root)
|
||||||
|
|
||||||
|
assert [entry.name for entry in navigator.visible_entries] == ["..", "visible"]
|
||||||
|
|
||||||
|
navigator.toggle_hidden()
|
||||||
|
assert navigator.show_hidden is True
|
||||||
|
assert [entry.name for entry in navigator.visible_entries] == [
|
||||||
|
"..",
|
||||||
|
".hidden",
|
||||||
|
"visible",
|
||||||
|
]
|
||||||
|
|
||||||
|
navigator.toggle_hidden()
|
||||||
|
assert navigator.show_hidden is False
|
||||||
|
assert [entry.name for entry in navigator.visible_entries] == ["..", "visible"]
|
||||||
|
|
||||||
|
|
||||||
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user