commit f03417f2f2e6d75d32e616cf9ca68bbd5754c0ca Author: Saky Date: Sat Mar 14 17:21:49 2026 +0100 Initial release: cd-browser v0.1.0 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..56f3b6b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b148a25 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Application settings +APP_NAME="New Project" +APP_ENV="development" +APP_DEBUG=true + +# Logging +LOG_LEVEL="INFO" + +# Example external service configuration +API_URL="https://api.example.com" +API_KEY="your-api-key-here" + +# Database example (optional) +DATABASE_URL="sqlite:///app.db" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a859e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Virtual environments +.venv/ +venv/ + +# Python cache +__pycache__/ +*.pyc +*.pyo +*.pyd + +# Tool caches +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# Build artifacts +build/ +dist/ +*.egg-info/ + +# IDEs +.vscode/ +.idea/ + +# OS files +.DS_Store + +.env/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..adc0a25 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +repos: + - repo: https://github.com/psf/black + rev: 24.10.0 + hooks: + - id: black + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.9.10 + hooks: + - id: ruff + - id: ruff-format + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: end-of-file-fixer diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2419ad5 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11.9 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..743cfba --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +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. + +## [1.0.0] - 2026-03-14 + +### Added +- Initial release of the Python AI Dev Template +- Universal Python project structure using `src/` +- `pyproject.toml`-based project configuration +- Development tooling with `black`, `ruff`, `mypy`, and `pytest` +- `pre-commit` integration +- `Makefile` with development, quality, and diagnostic commands +- `instructions-agent.md` for AI coding agent guidance +- `.env.example` for environment-based configuration +- README with setup, workflow, and template reuse instructions +- MIT license diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6be6419 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,181 @@ +# Contributing + +Thank you for your interest in contributing to this project. + +This repository is intended to be a clean, reusable, and professional Python project template. Contributions should preserve that goal. + +## General Principles + +- Keep the template generic and adaptable. +- Prefer clarity over cleverness. +- Avoid unnecessary complexity. +- Follow the existing project structure and conventions. +- Write all code, comments, docstrings, and documentation in English. + +## Development Setup + +It is recommended to use the provided Makefile. + +### First-time setup + +```bash +make dev +# Contributing + +Thank you for your interest in contributing to this project. + +This repository is intended to be a **clean, reusable, and professional Python project template**. +All contributions should aim to **improve the template without making it specific to a single project**. + +The goal is to keep this repository: + +- simple +- professional +- maintainable +- widely reusable + +--- + +# General Principles + +When contributing, please follow these principles: + +- Keep the template **generic and adaptable**. +- Prefer **clarity over cleverness**. +- Avoid unnecessary complexity. +- Follow the existing **project structure and conventions**. +- Keep the repository **easy to understand for new developers**. +- Write all **code, comments, docstrings, and documentation in English**. + +This repository should remain a **solid starting point for many different types of Python projects**. + +--- + +# Development Setup + +It is recommended to use the provided **Makefile** to set up the development environment. + +## First-time setup + +```bash +make dev +``` + +This command will: + +- create the `.venv` virtual environment +- install project dependencies +- install development tools +- configure `pre-commit` hooks + +--- + +# Development Workflow + +Typical workflow when working on the project: + +```bash +make fix +make quality +make run +``` + +Command summary: + +| Command | Description | +|-------|-------------| +| `make dev` | bootstrap development environment | +| `make install` | reinstall dependencies | +| `make fix` | auto-format and fix code issues | +| `make quality` | run formatting, linting, type checks, and tests | +| `make ci` | simulate CI validation | +| `make doctor` | show environment and tool versions | +| `make run` | run the application | + +--- + +# Code Standards + +All contributions should follow these standards: + +- Python **3.11+** +- Use **type hints** where appropriate +- Write **clean and modular code** +- Prefer **readable code over clever shortcuts** +- Avoid dead code +- Use **consistent docstrings** for public APIs + +Tools used in this project: + +- **black** → formatting +- **ruff** → linting +- **mypy** → static typing +- **pytest** → testing + +--- + +# Quality Checks + +Before submitting changes, run: + +```bash +make fix +make quality +pre-commit run --all-files +``` + +All checks should pass before committing. + +--- + +# Documentation + +If you modify project structure, development workflow, or setup instructions, please update the relevant documentation: + +- `README.md` +- `CHANGELOG.md` +- `instructions-agent.md` + +Clear documentation is as important as clean code. + +--- + +# Commit Messages + +Use clear and concise commit messages. + +Examples: + +``` +Add make doctor command +Improve README template usage instructions +Add environment configuration example +Fix formatting issues +``` + +Good commit messages make the project history easier to understand. + +--- + +# Scope of Contributions + +Good contributions include: + +- improving the project structure +- improving development workflow +- improving documentation +- improving tooling and automation +- fixing template inconsistencies +- simplifying developer experience + +Please avoid turning this repository into a **project-specific application**. + +This repository should remain a **general-purpose Python template**. + +--- + +# Acknowledgements + +This template was created by **Saky**, drawing on many years of professional software engineering experience and with the assistance of modern AI development tools. + +Contributions that improve the template while preserving its simplicity are always welcome. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9ad9898 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Saky + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e872e65 --- /dev/null +++ b/Makefile @@ -0,0 +1,70 @@ +PYTHON := python +PIP := pip + +.PHONY: help install dev format fix check lint typecheck test run quality ci doctor clean + +help: + @echo "Available commands:" + @echo " make install Install project with development dependencies" + @echo " make dev Create local dev environment and install tools" + @echo " make format Format source code with Black" + @echo " make fix Auto-fix code issues (Black + Ruff)" + @echo " make check Check formatting with Black" + @echo " make lint Run Ruff" + @echo " make typecheck Run MyPy" + @echo " make test Run pytest" + @echo " make run Run the application" + @echo " make quality Run all quality checks" + @echo " make ci Run CI-style validation checks" + @echo " make doctor Show environment and tool versions" + @echo " make clean Remove cache files" + +install: + $(PIP) install --upgrade pip + $(PIP) install -e '.[dev]' + +dev: + $(PYTHON) -m venv .venv + . .venv/bin/activate && pip install --upgrade pip + . .venv/bin/activate && pip install -e '.[dev]' + . .venv/bin/activate && pre-commit install + +format: + black src tests + +fix: + black src tests + ruff check --fix src tests + +check: + black --check src tests + +lint: + ruff check src tests + +typecheck: + mypy src + +test: + pytest + +run: + $(PYTHON) -m app.main + +quality: check lint typecheck test + +ci: check lint typecheck test + +doctor: + @echo "Python:" + @$(PYTHON) --version + @echo "" + @echo "Tool versions:" + @black --version + @ruff --version + @mypy --version + @pytest --version + +clean: + find . -type d \( -name "__pycache__" -o -name ".pytest_cache" -o -name ".mypy_cache" -o -name ".ruff_cache" \) -exec rm -rf {} + + find . -type f \( -name "*.pyc" -o -name "*.pyo" \) -delete diff --git a/README.md b/README.md new file mode 100644 index 0000000..76147ec --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# cd-browser + +`cd-browser` is a terminal-native directory navigator for fast filesystem browsing from the command line. + +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. + +## 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 + +Install the project in user mode: + +```bash +pip install . +``` + +For editable local development: + +```bash +pip install -e '.[dev]' +``` + +After installation, the application command is: + +```bash +cd_browser +``` + +When the interactive session exits, the program prints the final selected directory path. + +## Shell Integration For `cd_` + +Because a Python subprocess cannot directly change the parent shell directory, use a shell wrapper that captures the final path printed by `cd_browser`. + +Bash or Zsh example: + +```bash +cd_() { + local target + target="$(cd_browser)" || return + + if [ -n "$target" ] && [ -d "$target" ]; then + cd "$target" + fi +} +``` + +After adding the function to your shell profile, reload it: + +```bash +source ~/.bashrc +``` + +Or: + +```bash +source ~/.zshrc +``` + +Then use: + +```bash +cd_ +``` + +## Uninstall + +If the project was installed with `pip`, remove it with: + +```bash +pip uninstall cd-browser +``` + +If you also added the `cd_` shell wrapper, remove that function from your shell profile and reload the shell configuration. + +## Developer Setup + +Recommended setup: + +```bash +make dev +``` + +Run the application in development mode: + +```bash +python -m app.main +``` + +Useful development commands: + +```bash +make fix +make quality +make run +``` + +## Template Origin + +This project was created from the Python AI Dev Template. + +The original template documentation has been preserved in `README_TEMPLATE.md` so the project-specific README can focus on `cd-browser` usage and development. + +For the original template setup, conventions, and generic workflow notes, see `README_TEMPLATE.md`. diff --git a/README_TEMPLATE.md b/README_TEMPLATE.md new file mode 100644 index 0000000..c57e572 --- /dev/null +++ b/README_TEMPLATE.md @@ -0,0 +1,397 @@ +# Python AI Dev Template + +![Python](https://img.shields.io/badge/python-3.11+-blue) +![License](https://img.shields.io/badge/license-MIT-green) +![Style](https://img.shields.io/badge/code%20style-black-000000) +![Lint](https://img.shields.io/badge/lint-ruff-red) +![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen) + +A universal and adaptable **Python project template** designed for professional development and AI‑assisted coding workflows (OpenCode, AI agents, etc.). + +License: MIT + +This repository provides a clean and production‑ready starting point for building: + +- CLI tools +- backend services +- automation scripts +- desktop applications +- internal developer tools + +The template enforces **modern Python standards**, **clean architecture**, and **automated quality checks**. + +--- + +# Requirements + +- Python **3.11+** +- `pyenv` recommended for Python version management + +Check Python version: + +```bash +python --version +``` + +--- + +# Quick Start + +Clone the template repository: + +```bash +git clone new-project +cd new-project +``` + +Create a virtual environment: + +```bash +python -m venv .venv +source .venv/bin/activate +``` + +Install development dependencies: + +```bash +pip install --upgrade pip +pip install -e '.[dev]' +``` + +--- + +# Using the Makefile (Recommended) + +This template includes a **Makefile** to simplify common development tasks. + +### First time setup + +To prepare the full development environment automatically: + +```bash +make dev +``` + +This command will: + +- create the `.venv` virtual environment +- install project dependencies +- install development tools +- configure `pre-commit` hooks + +This is the **recommended command after cloning the repository**. + +### Reinstall or update dependencies + +If the virtual environment already exists and you only want to reinstall or update dependencies: + +```bash +make install +``` + +### Development utilities + +Some useful commands: + +```bash +make fix # auto‑format and fix code issues +make quality # run full quality checks +make ci # simulate CI validation +make doctor # show environment and tool versions +make run # run the application +``` + +Using the Makefile ensures a consistent development workflow across machines. + +--- + +# Environment Configuration + +This template supports **environment-based configuration** using a `.env` file. + +An example configuration file is provided: + +``` +.env.example +``` + +To create your local configuration, copy the example file: + +```bash +cp .env.example .env +``` + +Then edit `.env` and adjust the values for your local environment. + +Typical uses include: + +- application settings +- API endpoints +- API keys +- database connection strings +- feature flags + +⚠️ The `.env` file is **ignored by git** and should **never contain secrets in the repository**. + +Only `.env.example` should be committed. + +--- + +# Run the Application + +```bash +python -m app.main +``` + +Expected output: + +``` +cd-browser is ready. +``` + +--- + +# Development Workflow + +Before committing changes, always validate the project using the following steps. + +### 1. Run the application + +```bash +python -m app.main +``` + +### 2. Run tests + +```bash +pytest +``` + +### 3. Format code + +```bash +black src tests +``` + +### 4. Check formatting + +```bash +black --check src tests +``` + +### 5. Lint code + +```bash +ruff check src tests +``` + +### 6. Type checking + +```bash +mypy src +``` + +If all commands succeed, the project is considered valid. + +--- + +# Quick Quality Check + +Run all checks quickly: + +```bash +black src tests +ruff check src tests +mypy src +pytest +``` + +--- + + +# Create a New Repository From This Template + +If you downloaded or cloned this template and want to start a **new project repository**, follow these steps. + +### 1. Clone the template + +```bash +git clone https://gitea.sakydogalo.es/saky/python-ai-dev-template.git new-project +cd new-project +``` + +### Recommended: Initialize the project automatically + +You can initialize a new project using the helper script: + +```bash +./scripts/init_project.sh my-project-name +``` + +This script will: + +- reset the git history +- prepare the repository for a fresh start +- keep the template structure intact + +After running the script, create your new remote repository and push the code as described below. + +--- + +### Manual initialization (alternative) + +If you prefer to perform the steps manually, follow the instructions below. + +### 2. Remove the original remote + +```bash +git remote remove origin +``` + +### 3. Reset Git history (start clean) + +```bash +rm -rf .git +git init +``` + +### 4. Update project metadata + +Update these files to match your new project: + +- `pyproject.toml` → project name and description +- `README.md` +- `src/app/config.py` → application name + +### 5. Validate the project + +```bash +make fix +make quality +``` + +### 6. Create your new repository + +Create a new repository on your Git server (Gitea, GitHub, etc.). + +Example: + +``` +https://gitea.sakydogalo.es/saky/new-project +``` + +### 7. Connect the new remote + +```bash +git remote add origin +``` + +### 8. Create the first commit + +```bash +git add . +git commit -m "Initial new-project from template" +``` + +### 9. Push to the new repository + +```bash +git branch -M main +git push -u origin main +``` + +Your new project repository is now ready. + +--- + +# Project Structure + +``` +new-project/ +│ +├── src/ +│ └── app/ +│ ├── main.py +│ ├── config.py +│ ├── logging_config.py +│ ├── services/ +│ ├── domain/ +│ ├── infrastructure/ +│ └── utils/ +│ +├── tests/ +│ └── test_smoke.py +│ +├── scripts/ +│ +├── pyproject.toml +├── instructions-agent.md +├── README.md +└── .editorconfig +``` + +--- + +# Tooling + +This template includes modern Python development tools: + +| Tool | Purpose | +|-----|--------| +| pytest | testing framework | +| black | automatic code formatting | +| ruff | linting and static analysis | +| mypy | static type checking | + +--- + +# Purpose of this Template + +This repository is intentionally **generic and adaptable**. + +It can evolve into: + +- a CLI tool +- a web API +- a microservice +- a desktop application +- an automation system + +The goal is to provide a **clean, maintainable, and AI‑friendly Python project foundation**. + +--- + +# AI Agent Support + +This repository includes: + +instructions-agent.md + +This file defines rules and expectations for AI coding agents such as: + +- OpenCode +- AI IDE assistants +- autonomous coding agents + +Agents should follow those instructions when modifying the project. + +--- + +# License + +This project is released under the **MIT License**, one of the most permissive open‑source licenses available. + +You are free to: + +- use this code for personal or commercial projects +- modify it +- distribute it +- include it in proprietary software + +No special permission is required. + +Copyright (c) 2025 Saky + +This template was created by **Saky**, drawing on many years of professional software engineering experience and with the assistance of modern AI development tools. + +The goal of this repository is to provide a practical, production‑ready starting point for Python projects while remaining as open and reusable as possible. diff --git a/instructions-agent.md b/instructions-agent.md new file mode 100644 index 0000000..8900952 --- /dev/null +++ b/instructions-agent.md @@ -0,0 +1,61 @@ +# Agent Instructions: Universal Python Project Template + +## Project Overview +This repository is a universal and adaptable Python template intended to serve as a professional starting point for new projects. + +## Core Directives +1. All code, comments, variables, string literals, commit messages, and documentation must be written in English. +2. Use Python 3.11+ with type hints whenever possible. +3. Follow clean code principles and PEP 8. +4. Keep modules small, cohesive, and maintainable. +5. Use consistent docstrings for public classes and functions. +6. Prefer explicit error handling over silent failures. + +## Architecture Guidelines +1. Keep business logic separate from infrastructure concerns. +2. Avoid mixing configuration, I/O, and domain logic in the same module. +3. Prefer reusable services over duplicated logic. +4. Do not introduce unnecessary complexity or premature abstractions. + +## Quality Standards +1. Every meaningful feature should include tests. +2. Run formatting, linting, and type checks before considering a task complete. +3. Keep the README updated whenever setup, commands, or structure changes. +4. Do not leave placeholder code unless clearly marked. + +## Tooling +- Testing: pytest +- Formatting: black +- Linting: ruff +- Static typing: mypy + +## Expected Project Structure +src/app/ +tests/ +scripts/ +README.md +instructions-agent.md +pyproject.toml + +## Agent Workflow +1. First understand the repository structure. +2. Propose a short implementation plan before major changes. +3. Reuse existing modules whenever possible. +4. Keep changes minimal, coherent, and production-oriented. +5. Validate changes with tests and quality tools when possible. + +## Application Specification + +This repository includes an application specification that defines the expected behavior of the project. + +Primary specification file: + +- `specs/cd_browser_spec.md` + +Agent rules: + +1. Before implementing any feature, read the application specification. +2. If implementation details are unclear, follow the specification first. +3. If the specification conflicts with a previous assumption, the specification takes precedence. +4. Keep implementation aligned with the MVP scope unless explicitly asked to extend it. +5. After code changes, run the appropriate validation workflow defined by this repository. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e8bb8f1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,56 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "cd-browser" +version = "0.1.0" +description = "A universal and adaptable Python project template for AI-assisted development." +readme = "README.md" +requires-python = ">=3.11" +authors = [ + { name = "Saky" } +] +dependencies = [] + +[project.scripts] +cd_browser = "app.main:main" + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "black>=24.0", + "ruff>=0.3.0", + "mypy>=1.8.0", + "pre-commit>=3.7.0" +] + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.black] +line-length = 88 +target-version = ["py311"] + +[tool.ruff] +line-length = 88 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP"] +ignore = [] + +[tool.mypy] +python_version = "3.11" +strict = true +warn_unused_configs = true +disallow_untyped_defs = true +ignore_missing_imports = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] +addopts = "-q" diff --git a/scripts/format.sh b/scripts/format.sh new file mode 100755 index 0000000..959f17e --- /dev/null +++ b/scripts/format.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +black src tests diff --git a/scripts/init_project.sh b/scripts/init_project.sh new file mode 100755 index 0000000..d3880a6 --- /dev/null +++ b/scripts/init_project.sh @@ -0,0 +1,68 @@ + +#!/usr/bin/env bash + +# This script is designed to work on: +# - macOS +# - Linux +# - Windows when using Git Bash (recommended) +# +# On Windows, run it from Git Bash: +# ./scripts/init_project.sh my-project-name + +set -e + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd) +cd "$REPO_ROOT" + +if [ -d ".git" ]; then + echo "Error: this repository still contains a .git directory." + echo "Please remove .git first or run this script only on a fresh copy of the template." + exit 1 +fi + +PROJECT_NAME=${1:-} + +if [ -z "$PROJECT_NAME" ]; then + echo "Usage: ./scripts/init_project.sh " + exit 1 +fi + +if ! printf '%s' "$PROJECT_NAME" | grep -Eq '^[a-zA-Z0-9._-]+$'; then + echo "Error: project name contains unsupported characters." + echo "Use only letters, numbers, dots, underscores, and hyphens." + exit 1 +fi + +echo "Creating project: $PROJECT_NAME" +echo "Working directory: $REPO_ROOT" + + +# rename project metadata (portable sed usage for macOS, Linux, and Git Bash on Windows) +# create temporary backup files (.bak) and remove them after replacement +if [ ! -f "README.md" ] || [ ! -f "pyproject.toml" ]; then + echo "Error: README.md or pyproject.toml not found in repository root." + exit 1 +fi +sed -i.bak "s/New Project/$PROJECT_NAME/g" README.md +sed -i.bak "s/python-ai-dev-template/$PROJECT_NAME/g" pyproject.toml + +rm -f README.md.bak pyproject.toml.bak + +# reset git history +rm -rf .git +git init +git branch -M main + +echo "" +echo "Project initialized." +echo "" +echo "Next steps:" +echo "" +echo "git add ." +echo "git commit -m \"Initial $PROJECT_NAME\"" +echo "" +echo "Create a remote repository and run:" +echo "" +echo "git remote add origin " +echo "git push -u origin main" diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100755 index 0000000..f05be34 --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +ruff check src tests diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..e5a7b59 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +pytest diff --git a/scripts/typecheck.sh b/scripts/typecheck.sh new file mode 100755 index 0000000..d9aa1a5 --- /dev/null +++ b/scripts/typecheck.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +mypy src diff --git a/specs/cd_browser_spec.md b/specs/cd_browser_spec.md new file mode 100644 index 0000000..e4ccb57 --- /dev/null +++ b/specs/cd_browser_spec.md @@ -0,0 +1,305 @@ +# cd_ — Terminal Directory Navigator + +## Overview + +`cd_` is an interactive terminal utility designed to make directory navigation faster and more intuitive directly from the command line. + +Instead of manually typing `cd` commands, users can browse directories using a keyboard-driven interface. + +The tool behaves like a **lightweight directory browser inside the terminal**. + +--- + +# Goals + +The utility should provide: + +- fast directory navigation +- keyboard-driven interface +- minimal latency +- intuitive exploration of directory trees +- ability to preview folder structure without entering directories + +The tool must remain **lightweight and terminal-native**. + +--- + +# Execution + +The tool is executed from the terminal: + +```bash +cd_ +``` + +When executed, it opens an **interactive terminal navigation interface**. + +--- + +# Interface Behavior + +The interface displays: + +- the current working directory +- a list of subdirectories +- a special entry: + +``` +.. +``` + +which navigates to the parent directory. + +Example: + +``` +/home/user/projects + +▶ backend +▶ scripts + docs +.. +``` + +--- + +# Directory Indicators + +Directories must visually indicate whether they contain subdirectories. + +Recommended symbols: + +| Symbol | Meaning | +|------|------| +| ▶ | collapsed directory containing subdirectories | +| ▼ | expanded directory | +| space | directory without subdirectories | + +Example: + +``` +▶ backend +▼ scripts + build + deploy + docs +``` + +--- + +# Navigation Controls + +## Arrow Up / Arrow Down + +Move selection between entries. + +--- + +## Enter + +Enter the selected directory. + +If `..` is selected, navigate to the parent directory. + +--- + +## ESC + +Exit interactive mode. + +The shell should remain in the directory currently selected. + +--- + +# Tree Expansion + +Users should be able to explore directory structure without entering directories. + +## Arrow Right + +Expand the selected directory. + +Example: + +Before: + +``` +▶ scripts +``` + +After pressing →: + +``` +▼ scripts + build + deploy + tools +``` + +--- + +## Arrow Left + +Collapse expanded directory. + +--- + +# Navigation History + +The application maintains a session navigation history. + +--- + +## h + +Display navigation history. + +The user can select a previous directory from the list. + +--- + +## b + +Navigate backward in history. + +--- + +## f + +Navigate forward in history. + +--- + +# UI Principles + +The interface must be: + +- responsive +- minimal +- keyboard-focused +- easy to understand visually + +--- + +# Future Extensions + +These features are optional and may be added later. + +## Search Mode + +Activated by pressing: + +``` +/ +``` + +Allows filtering directories by name. + +--- + +## Favorites + +Mark current directory: + +``` +m +``` + +Open favorites: + +``` +F +``` + +--- + +# Technical Constraints + +The implementation must: + +- be written in Python +- follow the repository coding standards +- pass formatting, linting, typing, and tests + +Validation commands: + +``` +make fix +make quality +``` + +--- + +# Installation Requirements + +The tool must support two usage modes. + +## Developer Mode + +Clone repository and run: + +``` +make dev +``` + +Then execute: + +``` +python -m app.main +``` + +--- + +## User Mode (Installable CLI) + +The tool should be installable as a CLI command. + +Example: + +``` +pip install . +``` + +After installation, users should be able to run: + +``` +cd_ +``` + +directly from the terminal. + +--- + +# Shell Integration Constraint + +Because a subprocess cannot change the parent shell directory, the tool must output the selected directory path. + +Shell integration should be implemented using a wrapper function. + +Example: + +```bash +cd_() { + cd "$(cd_browser)" +} +``` + +--- + +# MVP Scope + +The first version must include: + +- directory navigation +- tree expansion +- keyboard controls +- session history +- terminal interface + +Advanced features are optional. + +--- + +# Summary + +`cd_` is a terminal-native interactive directory navigator designed to accelerate filesystem navigation while remaining lightweight and keyboard-focused. diff --git a/src/app/__init__.py b/src/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/cli.py b/src/app/cli.py new file mode 100644 index 0000000..c676b2b --- /dev/null +++ b/src/app/cli.py @@ -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) diff --git a/src/app/config.py b/src/app/config.py new file mode 100644 index 0000000..2f1708f --- /dev/null +++ b/src/app/config.py @@ -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 diff --git a/src/app/domain/__init__.py b/src/app/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/filesystem.py b/src/app/filesystem.py new file mode 100644 index 0000000..26e5d62 --- /dev/null +++ b/src/app/filesystem.py @@ -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()) diff --git a/src/app/history.py b/src/app/history.py new file mode 100644 index 0000000..16fbf2a --- /dev/null +++ b/src/app/history.py @@ -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) diff --git a/src/app/infrastructure/__init__.py b/src/app/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/logging_config.py b/src/app/logging_config.py new file mode 100644 index 0000000..debf2a3 --- /dev/null +++ b/src/app/logging_config.py @@ -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, + ) diff --git a/src/app/main.py b/src/app/main.py new file mode 100644 index 0000000..13d1f71 --- /dev/null +++ b/src/app/main.py @@ -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() diff --git a/src/app/navigator.py b/src/app/navigator.py new file mode 100644 index 0000000..d93e3c4 --- /dev/null +++ b/src/app/navigator.py @@ -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") diff --git a/src/app/services/__init__.py b/src/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/ui.py b/src/app/ui.py new file mode 100644 index 0000000..de509c8 --- /dev/null +++ b/src/app/ui.py @@ -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) diff --git a/src/app/utils/__init__.py b/src/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..f1172a4 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,43 @@ +from pathlib import Path + +import pytest + +from app.cli import run_cli +from app.navigator import Navigator + + +class StubUI: + def __init__(self, result: Path) -> None: + self.result = result + self.navigator: Navigator | None = None + + def run(self, navigator: Navigator) -> Path: + self.navigator = navigator + return self.result + + +def test_run_cli_uses_current_working_directory( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + stub_ui = StubUI(tmp_path) + monkeypatch.setattr(Path, "cwd", lambda: tmp_path) + + result = run_cli(ui=stub_ui) + + assert result == tmp_path.resolve() + assert stub_ui.navigator is not None + assert stub_ui.navigator.current_path == tmp_path.resolve() + + +def test_run_cli_uses_provided_start_path(tmp_path: Path) -> None: + start_path = tmp_path / "workspace" + start_path.mkdir() + final_path = tmp_path / "final" + final_path.mkdir() + stub_ui = StubUI(final_path) + + result = run_cli(start_path=start_path, ui=stub_ui) + + assert result == final_path.resolve() + assert stub_ui.navigator is not None + assert stub_ui.navigator.current_path == start_path.resolve() diff --git a/tests/test_filesystem.py b/tests/test_filesystem.py new file mode 100644 index 0000000..9569e2f --- /dev/null +++ b/tests/test_filesystem.py @@ -0,0 +1,46 @@ +from pathlib import Path + +import pytest + +from app.filesystem import DirectoryEntry, has_subdirectories, list_directories + + +def test_has_subdirectories_detects_nested_directory(tmp_path: Path) -> None: + parent = tmp_path / "parent" + child = parent / "child" + child.mkdir(parents=True) + + assert has_subdirectories(parent) is True + + +def test_has_subdirectories_returns_false_without_nested_directories( + tmp_path: Path, +) -> None: + directory = tmp_path / "empty" + directory.mkdir() + (directory / "file.txt").write_text("content", encoding="utf-8") + + assert has_subdirectories(directory) is False + + +def test_list_directories_returns_sorted_directory_metadata(tmp_path: Path) -> None: + alpha = tmp_path / "alpha" + beta = tmp_path / "Beta" + file_path = tmp_path / "notes.txt" + + alpha.mkdir() + beta.mkdir() + (beta / "nested").mkdir() + file_path.write_text("ignore me", encoding="utf-8") + + assert list_directories(tmp_path) == [ + DirectoryEntry(name="alpha", path=alpha, has_children=False), + DirectoryEntry(name="Beta", path=beta, has_children=True), + ] + + +def test_list_directories_raises_for_missing_directory(tmp_path: Path) -> None: + missing_path = tmp_path / "missing" + + with pytest.raises(FileNotFoundError): + list_directories(missing_path) diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 0000000..f33333a --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,84 @@ +from pathlib import Path + +import pytest + +from app.history import SessionHistory + + +def test_visit_records_current_directory(tmp_path: Path) -> None: + history = SessionHistory() + first = tmp_path / "first" + first.mkdir() + + visited = history.visit(first) + + assert visited == first.resolve() + assert history.current == first.resolve() + assert history.entries() == (first.resolve(),) + + +def test_visit_drops_forward_history_after_new_branch(tmp_path: Path) -> None: + history = SessionHistory() + first = tmp_path / "first" + second = tmp_path / "second" + third = tmp_path / "third" + for path in (first, second, third): + path.mkdir() + + history.visit(first) + history.visit(second) + assert history.back() == first.resolve() + + history.visit(third) + + assert history.entries() == (first.resolve(), third.resolve()) + assert history.current == third.resolve() + assert history.forward() is None + + +def test_visit_does_not_duplicate_current_entry(tmp_path: Path) -> None: + history = SessionHistory() + directory = tmp_path / "directory" + directory.mkdir() + + history.visit(directory) + history.visit(directory) + + assert history.entries() == (directory.resolve(),) + + +def test_back_and_forward_move_through_history(tmp_path: Path) -> None: + history = SessionHistory() + first = tmp_path / "first" + second = tmp_path / "second" + for path in (first, second): + path.mkdir() + + history.visit(first) + history.visit(second) + + assert history.back() == first.resolve() + assert history.back() is None + assert history.forward() == second.resolve() + assert history.forward() is None + + +def test_select_moves_to_specific_history_entry(tmp_path: Path) -> None: + history = SessionHistory() + first = tmp_path / "first" + second = tmp_path / "second" + for path in (first, second): + path.mkdir() + + history.visit(first) + history.visit(second) + + assert history.select(0) == first.resolve() + assert history.current == first.resolve() + + +def test_select_raises_for_invalid_index() -> None: + history = SessionHistory() + + with pytest.raises(IndexError): + history.select(0) diff --git a/tests/test_navigator.py b/tests/test_navigator.py new file mode 100644 index 0000000..804ea51 --- /dev/null +++ b/tests/test_navigator.py @@ -0,0 +1,182 @@ +from pathlib import Path + +import pytest + +from app.history import SessionHistory +from app.navigator import Navigator + + +def test_navigator_initial_state_includes_parent_entry(tmp_path: Path) -> None: + current = tmp_path / "workspace" + current.mkdir() + alpha = current / "alpha" + beta = current / "beta" + alpha.mkdir() + beta.mkdir() + + navigator = Navigator(current) + + assert navigator.current_path == current.resolve() + assert navigator.selected_index == 0 + assert [entry.name for entry in navigator.visible_entries] == [ + "..", + "alpha", + "beta", + ] + + +def test_expand_selected_directory_reveals_nested_entries(tmp_path: Path) -> None: + current = tmp_path / "workspace" + current.mkdir() + scripts = current / "scripts" + scripts.mkdir() + build = scripts / "build" + build.mkdir() + + navigator = Navigator(current) + navigator.set_selected_index(1) + + assert navigator.expand_selected_directory() is True + assert [(entry.name, entry.depth) for entry in navigator.visible_entries] == [ + ("..", 0), + ("scripts", 0), + ("build", 1), + ] + + +def test_expand_selected_directory_returns_false_without_children( + tmp_path: Path, +) -> None: + current = tmp_path / "workspace" + current.mkdir() + docs = current / "docs" + docs.mkdir() + + navigator = Navigator(current) + navigator.set_selected_index(1) + + assert navigator.expand_selected_directory() is False + assert [entry.name for entry in navigator.visible_entries] == ["..", "docs"] + + +def test_collapse_selected_directory_hides_nested_entries(tmp_path: Path) -> None: + current = tmp_path / "workspace" + current.mkdir() + scripts = current / "scripts" + scripts.mkdir() + (scripts / "build").mkdir() + + navigator = Navigator(current) + navigator.set_selected_index(1) + navigator.expand_selected_directory() + + assert navigator.collapse_selected_directory() is True + assert [entry.name for entry in navigator.visible_entries] == ["..", "scripts"] + + +def test_collapse_selected_child_moves_to_parent_and_collapses(tmp_path: Path) -> None: + current = tmp_path / "workspace" + current.mkdir() + scripts = current / "scripts" + scripts.mkdir() + (scripts / "build").mkdir() + + navigator = Navigator(current) + navigator.set_selected_index(1) + navigator.expand_selected_directory() + navigator.set_selected_index(2) + + assert navigator.collapse_selected_directory() is True + assert navigator.selected_entry.name == "scripts" + assert [entry.name for entry in navigator.visible_entries] == ["..", "scripts"] + + +def test_enter_selected_directory_changes_current_path_and_updates_history( + tmp_path: Path, +) -> None: + current = tmp_path / "workspace" + current.mkdir() + child = current / "child" + child.mkdir() + + history = SessionHistory() + navigator = Navigator(current, history=history) + navigator.set_selected_index(1) + + selected_path = navigator.enter_selected_directory() + + assert selected_path == child.resolve() + assert navigator.current_path == child.resolve() + assert navigator.selected_index == 0 + assert navigator.history.entries() == (current.resolve(), child.resolve()) + + +def test_enter_parent_entry_moves_to_parent_directory(tmp_path: Path) -> None: + parent = tmp_path / "parent" + parent.mkdir() + current = parent / "current" + current.mkdir() + + navigator = Navigator(current) + + moved_path = navigator.enter_selected_directory() + + assert moved_path == parent.resolve() + assert navigator.current_path == parent.resolve() + + +def test_go_to_parent_directory_moves_up_one_level(tmp_path: Path) -> None: + parent = tmp_path / "parent" + parent.mkdir() + current = parent / "current" + current.mkdir() + + navigator = Navigator(current) + + assert navigator.go_to_parent_directory() == parent.resolve() + assert navigator.current_path == parent.resolve() + + +def test_history_navigation_restores_previous_path(tmp_path: Path) -> None: + root = tmp_path / "root" + root.mkdir() + first = root / "first" + second = root / "second" + first.mkdir() + second.mkdir() + + navigator = Navigator(root) + navigator.set_selected_index(1) + navigator.enter_selected_directory() + navigator.go_to_parent_directory() + navigator.set_selected_index(2) + navigator.enter_selected_directory() + + assert navigator.go_back() == root.resolve() + assert navigator.current_path == root.resolve() + assert navigator.go_back() == first.resolve() + assert navigator.go_forward() == root.resolve() + assert navigator.go_forward() == second.resolve() + + +def test_select_history_entry_changes_current_path(tmp_path: Path) -> None: + root = tmp_path / "root" + root.mkdir() + child = root / "child" + child.mkdir() + + navigator = Navigator(root) + navigator.set_selected_index(1) + navigator.enter_selected_directory() + + assert navigator.select_history_entry(0) == root.resolve() + assert navigator.current_path == root.resolve() + + +def test_set_selected_index_raises_for_invalid_index(tmp_path: Path) -> None: + current = tmp_path / "workspace" + current.mkdir() + navigator = Navigator(current) + + with pytest.raises(IndexError): + navigator.set_selected_index(1) diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..70897db --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,18 @@ +from pathlib import Path + +import pytest + +from app.main import main + + +def test_main_runs( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr("app.main.run_cli", lambda: Path("/tmp/final")) + + main() + + captured = capsys.readouterr() + + assert captured.out == "/tmp/final\n" + assert captured.err == "" diff --git a/tests/test_ui.py b/tests/test_ui.py new file mode 100644 index 0000000..ae9f6d7 --- /dev/null +++ b/tests/test_ui.py @@ -0,0 +1,80 @@ +from pathlib import Path + +import pytest + +from app.navigator import Navigator, VisibleEntry +from app.ui import ( + RenderLine, + build_render_lines, + format_entry, + move_selection, + open_terminal_streams, +) + + +def test_move_selection_stays_within_bounds() -> None: + assert move_selection(current_index=0, step=-1, entry_count=3) == 0 + assert move_selection(current_index=1, step=1, entry_count=3) == 2 + assert move_selection(current_index=2, step=1, entry_count=3) == 2 + + +def test_format_entry_supports_parent_and_tree_indicators(tmp_path: Path) -> None: + parent_entry = VisibleEntry( + name="..", + path=tmp_path, + depth=0, + has_children=True, + is_expanded=False, + is_parent=True, + ) + collapsed_entry = VisibleEntry( + name="scripts", + path=tmp_path / "scripts", + depth=0, + has_children=True, + is_expanded=False, + ) + expanded_child = VisibleEntry( + name="build", + path=tmp_path / "scripts" / "build", + depth=1, + has_children=False, + is_expanded=False, + ) + + assert format_entry(parent_entry) == ".." + assert format_entry(collapsed_entry) == "▶ scripts" + assert format_entry(expanded_child) == " build" + + +def test_build_render_lines_marks_selected_entry(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + alpha = workspace / "alpha" + beta = workspace / "beta" + alpha.mkdir() + beta.mkdir() + + navigator = Navigator(workspace) + navigator.set_selected_index(2) + + assert build_render_lines(navigator) == [ + RenderLine(text="..", is_selected=False), + RenderLine(text=" alpha", is_selected=False), + RenderLine(text=" beta", is_selected=True), + ] + + +def test_open_terminal_streams_raises_without_terminal( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def raise_os_error(*args: object, **kwargs: object) -> object: + raise OSError + + monkeypatch.setattr("builtins.open", raise_os_error) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + monkeypatch.setattr("sys.stderr.isatty", lambda: False) + + with pytest.raises(RuntimeError, match="Interactive terminal is required"): + with open_terminal_streams(): + pass