Initial release: cd-browser v0.1.0

This commit is contained in:
2026-03-14 17:21:49 +01:00
commit f03417f2f2
39 changed files with 2351 additions and 0 deletions

0
src/app/__init__.py Normal file
View File

15
src/app/cli.py Normal file
View File

@@ -0,0 +1,15 @@
from __future__ import annotations
from pathlib import Path
from app.navigator import Navigator
from app.ui import TerminalUI
def run_cli(start_path: Path | None = None, ui: TerminalUI | None = None) -> Path:
"""Run the interactive directory browser and return the selected path."""
initial_path = (start_path or Path.cwd()).expanduser().resolve()
navigator = Navigator(initial_path)
terminal_ui = ui or TerminalUI()
return terminal_ui.run(navigator)

9
src/app/config.py Normal file
View File

@@ -0,0 +1,9 @@
from dataclasses import dataclass
@dataclass(slots=True)
class AppConfig:
"""Application configuration."""
app_name: str = "Python AI Dev Template"
debug: bool = False

View File

40
src/app/filesystem.py Normal file
View File

@@ -0,0 +1,40 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True, slots=True)
class DirectoryEntry:
"""Directory metadata used by the navigator layer."""
name: str
path: Path
has_children: bool
def list_directories(path: Path) -> list[DirectoryEntry]:
"""Return direct child directories sorted by name."""
directory_path = path.expanduser().resolve()
entries: list[DirectoryEntry] = []
for child in directory_path.iterdir():
if child.is_dir():
entries.append(
DirectoryEntry(
name=child.name,
path=child,
has_children=has_subdirectories(child),
)
)
return sorted(entries, key=lambda entry: entry.name.casefold())
def has_subdirectories(path: Path) -> bool:
"""Return whether the directory contains at least one subdirectory."""
directory_path = path.expanduser().resolve()
return any(child.is_dir() for child in directory_path.iterdir())

68
src/app/history.py Normal file
View File

@@ -0,0 +1,68 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
@dataclass(slots=True)
class SessionHistory:
"""In-memory session history for visited directories."""
_entries: list[Path] = field(default_factory=list)
_index: int = -1
def visit(self, path: Path) -> Path:
"""Record a directory visit and discard forward history when needed."""
resolved_path = path.expanduser().resolve()
if self.current == resolved_path:
return resolved_path
if self._index < len(self._entries) - 1:
self._entries = self._entries[: self._index + 1]
self._entries.append(resolved_path)
self._index = len(self._entries) - 1
return resolved_path
@property
def current(self) -> Path | None:
"""Return the current history entry."""
if self._index == -1:
return None
return self._entries[self._index]
def back(self) -> Path | None:
"""Move backward in history when possible."""
if self._index <= 0:
return None
self._index -= 1
return self._entries[self._index]
def forward(self) -> Path | None:
"""Move forward in history when possible."""
if self._index == -1 or self._index >= len(self._entries) - 1:
return None
self._index += 1
return self._entries[self._index]
def select(self, index: int) -> Path:
"""Jump to a previously visited directory by history index."""
if index < 0 or index >= len(self._entries):
raise IndexError("History index out of range")
self._index = index
return self._entries[self._index]
def entries(self) -> tuple[Path, ...]:
"""Return the full history as an immutable sequence."""
return tuple(self._entries)

View File

11
src/app/logging_config.py Normal file
View File

@@ -0,0 +1,11 @@
import logging
import sys
def setup_logging() -> None:
"""Configure application logging."""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
stream=sys.stderr,
)

16
src/app/main.py Normal file
View File

@@ -0,0 +1,16 @@
import sys
from app.cli import run_cli
from app.logging_config import setup_logging
def main() -> None:
"""Run the application entry point."""
setup_logging()
final_path = run_cli()
sys.stdout.write(f"{final_path}\n")
if __name__ == "__main__":
main()

204
src/app/navigator.py Normal file
View File

@@ -0,0 +1,204 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from app.filesystem import list_directories
from app.history import SessionHistory
@dataclass(frozen=True, slots=True)
class VisibleEntry:
"""A directory row visible in the navigator."""
name: str
path: Path
depth: int
has_children: bool
is_expanded: bool
is_parent: bool = False
@dataclass(slots=True)
class Navigator:
"""State container for directory navigation behavior."""
start_path: Path
history: SessionHistory = field(default_factory=SessionHistory)
current_path: Path = field(init=False)
selected_index: int = field(init=False, default=0)
_expanded_paths: set[Path] = field(init=False, default_factory=set)
def __post_init__(self) -> None:
self.current_path = self.start_path.expanduser().resolve()
self.history.visit(self.current_path)
@property
def visible_entries(self) -> tuple[VisibleEntry, ...]:
"""Return the current flattened directory view."""
entries = [
VisibleEntry(
name="..",
path=self.current_path.parent,
depth=0,
has_children=True,
is_expanded=False,
is_parent=True,
)
]
entries.extend(self._build_entries(self.current_path, depth=0))
return tuple(entries)
@property
def selected_entry(self) -> VisibleEntry:
"""Return the currently selected visible entry."""
return self.visible_entries[self.selected_index]
def set_selected_index(self, index: int) -> None:
"""Set the selected index within the visible entry range."""
entry_count = len(self.visible_entries)
if index < 0 or index >= entry_count:
raise IndexError("Selected index out of range")
self.selected_index = index
def enter_selected_directory(self) -> Path:
"""Enter the currently selected directory and record it in history."""
return self._change_directory(self.selected_entry.path)
def go_to_parent_directory(self) -> Path:
"""Navigate to the parent directory of the current path."""
return self._change_directory(self.current_path.parent)
def expand_selected_directory(self) -> bool:
"""Expand the selected directory if it has children."""
return self.expand_directory(self.selected_entry.path)
def collapse_selected_directory(self) -> bool:
"""Collapse the selected directory when possible."""
selected = self.selected_entry
if selected.is_parent:
return False
if selected.path in self._expanded_paths:
self._collapse_path(selected.path)
return True
if selected.depth > 0:
parent = self._find_visible_parent(selected)
self.set_selected_index(self.visible_entries.index(parent))
return self.collapse_selected_directory()
return False
def expand_directory(self, path: Path) -> bool:
"""Expand a visible directory by path."""
resolved_path = path.expanduser().resolve()
for entry in self.visible_entries:
if (
entry.path == resolved_path
and not entry.is_parent
and entry.has_children
):
self._expanded_paths.add(resolved_path)
return True
return False
def collapse_directory(self, path: Path) -> bool:
"""Collapse a previously expanded directory by path."""
resolved_path = path.expanduser().resolve()
if resolved_path not in self._expanded_paths:
return False
self._collapse_path(resolved_path)
return True
def go_back(self) -> Path | None:
"""Move backward through navigation history."""
path = self.history.back()
if path is None:
return None
self._set_current_path(path)
return path
def go_forward(self) -> Path | None:
"""Move forward through navigation history."""
path = self.history.forward()
if path is None:
return None
self._set_current_path(path)
return path
def select_history_entry(self, index: int) -> Path:
"""Jump to a directory stored in session history."""
path = self.history.select(index)
self._set_current_path(path)
return path
def _build_entries(self, path: Path, depth: int) -> list[VisibleEntry]:
entries: list[VisibleEntry] = []
for directory in list_directories(path):
is_expanded = directory.path in self._expanded_paths
entries.append(
VisibleEntry(
name=directory.name,
path=directory.path,
depth=depth,
has_children=directory.has_children,
is_expanded=is_expanded,
)
)
if is_expanded:
entries.extend(self._build_entries(directory.path, depth=depth + 1))
return entries
def _change_directory(self, path: Path) -> Path:
resolved_path = path.expanduser().resolve()
self.history.visit(resolved_path)
self._set_current_path(resolved_path)
return resolved_path
def _set_current_path(self, path: Path) -> None:
self.current_path = path.expanduser().resolve()
self.selected_index = 0
self._expanded_paths.clear()
def _collapse_path(self, path: Path) -> None:
self._expanded_paths = {
expanded_path
for expanded_path in self._expanded_paths
if expanded_path != path and path not in expanded_path.parents
}
def _find_visible_parent(self, entry: VisibleEntry) -> VisibleEntry:
entries = self.visible_entries
current_index = entries.index(entry)
for candidate_index in range(current_index - 1, -1, -1):
candidate = entries[candidate_index]
if (
candidate.depth == entry.depth - 1
and entry.path.parent == candidate.path
):
return candidate
raise ValueError("Visible parent entry not found")

View File

167
src/app/ui.py Normal file
View File

@@ -0,0 +1,167 @@
from __future__ import annotations
import curses
import os
import sys
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import TextIO
from app.navigator import Navigator, VisibleEntry
ESCAPE_KEY = 27
@dataclass(frozen=True, slots=True)
class RenderLine:
"""A rendered navigator row ready for terminal output."""
text: str
is_selected: bool
def move_selection(current_index: int, step: int, entry_count: int) -> int:
"""Move the selected index within the available entry range."""
if entry_count <= 0:
return 0
return max(0, min(current_index + step, entry_count - 1))
def format_entry(entry: VisibleEntry) -> str:
"""Format a visible entry for terminal display."""
if entry.is_parent:
return entry.name
indent = " " * entry.depth
indicator = " "
if entry.has_children:
indicator = "" if entry.is_expanded else ""
return f"{indent}{indicator} {entry.name}"
def build_render_lines(navigator: Navigator) -> list[RenderLine]:
"""Build all visible lines for the current navigator state."""
return [
RenderLine(
text=format_entry(entry),
is_selected=index == navigator.selected_index,
)
for index, entry in enumerate(navigator.visible_entries)
]
class TerminalUI:
"""Minimal curses UI for the directory navigator MVP."""
def run(self, navigator: Navigator) -> Path:
"""Start the interactive session and return the final directory path."""
with open_terminal_streams() as (input_stream, output_stream):
with redirect_standard_streams(input_stream, output_stream):
return curses.wrapper(self._run_session, navigator)
def _run_session(self, stdscr: curses.window, navigator: Navigator) -> Path:
curses.curs_set(0)
stdscr.keypad(True)
while True:
self._render(stdscr, navigator)
key = stdscr.getch()
if key == ESCAPE_KEY:
return navigator.current_path
if key == curses.KEY_UP:
navigator.set_selected_index(
move_selection(
navigator.selected_index,
step=-1,
entry_count=len(navigator.visible_entries),
)
)
continue
if key == curses.KEY_DOWN:
navigator.set_selected_index(
move_selection(
navigator.selected_index,
step=1,
entry_count=len(navigator.visible_entries),
)
)
continue
if key == curses.KEY_RIGHT:
navigator.expand_selected_directory()
continue
if key == curses.KEY_LEFT:
navigator.collapse_selected_directory()
continue
if key in (curses.KEY_ENTER, 10, 13):
navigator.enter_selected_directory()
def _render(self, stdscr: curses.window, navigator: Navigator) -> None:
stdscr.erase()
height, width = stdscr.getmaxyx()
path_text = str(navigator.current_path)
stdscr.addnstr(0, 0, path_text, width - 1)
for row, line in enumerate(build_render_lines(navigator), start=2):
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.refresh()
@contextmanager
def open_terminal_streams() -> Iterator[tuple[TextIO, TextIO]]:
"""Open terminal streams suitable for interactive rendering."""
try:
with (
open("/dev/tty", encoding="utf-8") as input_stream,
open("/dev/tty", "w", encoding="utf-8") as output_stream,
):
yield input_stream, output_stream
return
except OSError:
if not sys.stdin.isatty() or not sys.stderr.isatty():
raise RuntimeError("Interactive terminal is required") from None
yield sys.stdin, sys.stderr
@contextmanager
def redirect_standard_streams(
input_stream: TextIO, output_stream: TextIO
) -> Iterator[None]:
"""Temporarily redirect stdin and stdout to terminal-backed streams."""
sys.stdout.flush()
sys.stderr.flush()
original_stdin = os.dup(0)
original_stdout = os.dup(1)
try:
os.dup2(input_stream.fileno(), 0)
os.dup2(output_stream.fileno(), 1)
yield
finally:
os.dup2(original_stdin, 0)
os.dup2(original_stdout, 1)
os.close(original_stdin)
os.close(original_stdout)

View File