Initial release: cd-browser v0.1.0
This commit is contained in:
0
src/app/__init__.py
Normal file
0
src/app/__init__.py
Normal file
15
src/app/cli.py
Normal file
15
src/app/cli.py
Normal 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
9
src/app/config.py
Normal 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
|
||||
0
src/app/domain/__init__.py
Normal file
0
src/app/domain/__init__.py
Normal file
40
src/app/filesystem.py
Normal file
40
src/app/filesystem.py
Normal 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
68
src/app/history.py
Normal 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)
|
||||
0
src/app/infrastructure/__init__.py
Normal file
0
src/app/infrastructure/__init__.py
Normal file
11
src/app/logging_config.py
Normal file
11
src/app/logging_config.py
Normal 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
16
src/app/main.py
Normal 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
204
src/app/navigator.py
Normal 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")
|
||||
0
src/app/services/__init__.py
Normal file
0
src/app/services/__init__.py
Normal file
167
src/app/ui.py
Normal file
167
src/app/ui.py
Normal 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)
|
||||
0
src/app/utils/__init__.py
Normal file
0
src/app/utils/__init__.py
Normal file
Reference in New Issue
Block a user