Initial release: cd-browser v0.1.0
This commit is contained in:
12
.editorconfig
Normal file
12
.editorconfig
Normal file
@@ -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
|
||||
14
.env.example
Normal file
14
.env.example
Normal file
@@ -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"
|
||||
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@@ -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/
|
||||
16
.pre-commit-config.yaml
Normal file
16
.pre-commit-config.yaml
Normal file
@@ -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
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.11.9
|
||||
19
CHANGELOG.md
Normal file
19
CHANGELOG.md
Normal file
@@ -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
|
||||
181
CONTRIBUTING.md
Normal file
181
CONTRIBUTING.md
Normal file
@@ -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.
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -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.
|
||||
70
Makefile
Normal file
70
Makefile
Normal file
@@ -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
|
||||
111
README.md
Normal file
111
README.md
Normal file
@@ -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`.
|
||||
397
README_TEMPLATE.md
Normal file
397
README_TEMPLATE.md
Normal file
@@ -0,0 +1,397 @@
|
||||
# Python AI Dev Template
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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 <template-repository-url> 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 <your-new-repository-url>
|
||||
```
|
||||
|
||||
### 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.
|
||||
61
instructions-agent.md
Normal file
61
instructions-agent.md
Normal file
@@ -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.
|
||||
56
pyproject.toml
Normal file
56
pyproject.toml
Normal file
@@ -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"
|
||||
2
scripts/format.sh
Executable file
2
scripts/format.sh
Executable file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env bash
|
||||
black src tests
|
||||
68
scripts/init_project.sh
Executable file
68
scripts/init_project.sh
Executable file
@@ -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 <project-name>"
|
||||
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 <repo-url>"
|
||||
echo "git push -u origin main"
|
||||
2
scripts/lint.sh
Executable file
2
scripts/lint.sh
Executable file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env bash
|
||||
ruff check src tests
|
||||
2
scripts/test.sh
Executable file
2
scripts/test.sh
Executable file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env bash
|
||||
pytest
|
||||
2
scripts/typecheck.sh
Executable file
2
scripts/typecheck.sh
Executable file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env bash
|
||||
mypy src
|
||||
305
specs/cd_browser_spec.md
Normal file
305
specs/cd_browser_spec.md
Normal file
@@ -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.
|
||||
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
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
43
tests/test_cli.py
Normal file
43
tests/test_cli.py
Normal file
@@ -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()
|
||||
46
tests/test_filesystem.py
Normal file
46
tests/test_filesystem.py
Normal file
@@ -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)
|
||||
84
tests/test_history.py
Normal file
84
tests/test_history.py
Normal file
@@ -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)
|
||||
182
tests/test_navigator.py
Normal file
182
tests/test_navigator.py
Normal file
@@ -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)
|
||||
18
tests/test_smoke.py
Normal file
18
tests/test_smoke.py
Normal file
@@ -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 == ""
|
||||
80
tests/test_ui.py
Normal file
80
tests/test_ui.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user