Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f78f6d9ca | |||
| 8259b2e132 | |||
| 4052c6f71f | |||
| 0e94895349 | |||
| d9cf164b2c | |||
| a64bc768b5 | |||
| f7f09b895d | |||
| 10c3849fb3 | |||
| f28fef7758 | |||
| ab689cfbb4 | |||
| 9688ed0974 |
260
AGENT.md
260
AGENT.md
@@ -1,260 +0,0 @@
|
|||||||
# AGENT.md
|
|
||||||
|
|
||||||
AI Agent Development Guide
|
|
||||||
|
|
||||||
This file defines the operational rules that AI agents must follow when working in this repository.
|
|
||||||
|
|
||||||
The goal is to ensure:
|
|
||||||
- reproducible AI-assisted development
|
|
||||||
- traceable decisions
|
|
||||||
- consistent repository history
|
|
||||||
- safe automated contributions
|
|
||||||
|
|
||||||
This document complements:
|
|
||||||
- `instructions-agent.md`
|
|
||||||
- `docs/ai-worklog.md`
|
|
||||||
- `docs/ai-prompts.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Core Principles
|
|
||||||
|
|
||||||
Agents must prioritize:
|
|
||||||
|
|
||||||
1. Deterministic changes
|
|
||||||
2. Minimal scope modifications
|
|
||||||
3. Explicit logging of work
|
|
||||||
4. Repository stability
|
|
||||||
|
|
||||||
Agents must never:
|
|
||||||
|
|
||||||
- fabricate repository state
|
|
||||||
- invent files that do not exist
|
|
||||||
- modify unrelated parts of the project
|
|
||||||
- skip validation steps
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Development Workflow
|
|
||||||
|
|
||||||
Agents operate using two primary task types:
|
|
||||||
|
|
||||||
## PLAN
|
|
||||||
|
|
||||||
Used to:
|
|
||||||
- investigate problems
|
|
||||||
- inspect repository state
|
|
||||||
- propose architecture changes
|
|
||||||
|
|
||||||
PLAN tasks must:
|
|
||||||
- analyze the current implementation
|
|
||||||
- identify the root cause
|
|
||||||
- propose minimal changes
|
|
||||||
|
|
||||||
PLAN tasks **must not modify code**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## BUILD
|
|
||||||
|
|
||||||
Used to:
|
|
||||||
- implement changes
|
|
||||||
- update code
|
|
||||||
- add tests
|
|
||||||
- update documentation
|
|
||||||
|
|
||||||
BUILD tasks may:
|
|
||||||
- modify source files
|
|
||||||
- update tests
|
|
||||||
- update documentation
|
|
||||||
|
|
||||||
BUILD tasks must:
|
|
||||||
- remain minimal in scope
|
|
||||||
- preserve existing behavior unless explicitly changed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Validation Requirements
|
|
||||||
|
|
||||||
All BUILD tasks must run:
|
|
||||||
|
|
||||||
```
|
|
||||||
make fix
|
|
||||||
make quality
|
|
||||||
```
|
|
||||||
|
|
||||||
These commands must pass before changes are considered valid.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Logging System
|
|
||||||
|
|
||||||
All significant AI actions must be recorded.
|
|
||||||
|
|
||||||
Two log files are used:
|
|
||||||
|
|
||||||
```
|
|
||||||
docs/ai-worklog.md
|
|
||||||
docs/ai-prompts.md
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Worklog Entries
|
|
||||||
|
|
||||||
Executed work must be recorded in:
|
|
||||||
|
|
||||||
```
|
|
||||||
docs/ai-worklog.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Each entry must follow the standard format defined in `instructions-agent.md`.
|
|
||||||
|
|
||||||
Mandatory fields:
|
|
||||||
|
|
||||||
- Date (YYYY-MM-DD HH:MM)
|
|
||||||
- Task ID
|
|
||||||
- Agent
|
|
||||||
- Task
|
|
||||||
- Objective
|
|
||||||
- Scope
|
|
||||||
- Files Modified / Files Inspected
|
|
||||||
- Key Decisions / Proposed Changes
|
|
||||||
- Validation
|
|
||||||
- Result
|
|
||||||
- Open Issues
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Prompt Log
|
|
||||||
|
|
||||||
Prompts that trigger significant work must be recorded in:
|
|
||||||
|
|
||||||
```
|
|
||||||
docs/ai-prompts.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Each prompt entry must include:
|
|
||||||
|
|
||||||
- Date
|
|
||||||
- Task ID
|
|
||||||
- Agent
|
|
||||||
- Task
|
|
||||||
- Scope
|
|
||||||
- Prompt Summary
|
|
||||||
- Result Summary
|
|
||||||
|
|
||||||
Chain-of-thought reasoning must **never be stored**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Task IDs
|
|
||||||
|
|
||||||
All tasks must include a unique identifier.
|
|
||||||
|
|
||||||
Format:
|
|
||||||
|
|
||||||
```
|
|
||||||
BUILD-YYYYMMDD-XXX
|
|
||||||
PLAN-YYYYMMDD-XXX
|
|
||||||
```
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
```
|
|
||||||
BUILD-20260315-001
|
|
||||||
PLAN-20260316-002
|
|
||||||
```
|
|
||||||
|
|
||||||
This allows prompts and worklog entries to be correlated.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Git Rules
|
|
||||||
|
|
||||||
Agents must follow these repository rules.
|
|
||||||
|
|
||||||
Agents must **not commit or push** unless explicitly requested.
|
|
||||||
|
|
||||||
When committing:
|
|
||||||
|
|
||||||
Use structured commit messages.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```
|
|
||||||
feat: improve terminal navigation behavior
|
|
||||||
|
|
||||||
- refine tree navigation
|
|
||||||
- improve scrolling continuity
|
|
||||||
- update AI logs
|
|
||||||
```
|
|
||||||
|
|
||||||
Documentation-only updates should use:
|
|
||||||
|
|
||||||
```
|
|
||||||
docs:
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Code Modification Rules
|
|
||||||
|
|
||||||
Agents should prefer:
|
|
||||||
|
|
||||||
- small targeted patches
|
|
||||||
- isolated logic changes
|
|
||||||
- preserving existing abstractions
|
|
||||||
|
|
||||||
Agents should avoid:
|
|
||||||
|
|
||||||
- rewriting entire modules
|
|
||||||
- introducing unnecessary dependencies
|
|
||||||
- altering unrelated code paths
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# UI Changes
|
|
||||||
|
|
||||||
When modifying UI behavior:
|
|
||||||
|
|
||||||
- keep navigation predictable
|
|
||||||
- preserve keyboard ergonomics
|
|
||||||
- ensure selections remain visible
|
|
||||||
- maintain compatibility with history mode
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Testing Rules
|
|
||||||
|
|
||||||
Whenever logic changes:
|
|
||||||
|
|
||||||
- update existing tests
|
|
||||||
- add focused tests when appropriate
|
|
||||||
|
|
||||||
Tests should validate behavior, not implementation details.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Scope Discipline
|
|
||||||
|
|
||||||
Agents must strictly respect scope defined in prompts.
|
|
||||||
|
|
||||||
If additional changes appear necessary:
|
|
||||||
|
|
||||||
Agents must propose them in a PLAN task before implementing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Repository Safety
|
|
||||||
|
|
||||||
Agents must not:
|
|
||||||
|
|
||||||
- delete important files
|
|
||||||
- alter project configuration without justification
|
|
||||||
- introduce breaking changes without explicit instruction
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# End of Document
|
|
||||||
---
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
# AGENT_WORKFLOW.md
|
|
||||||
|
|
||||||
AI Agent Operational Workflow
|
|
||||||
|
|
||||||
This document defines the deterministic workflow that AI agents must follow when performing work in this repository.
|
|
||||||
|
|
||||||
The workflow ensures:
|
|
||||||
|
|
||||||
- predictable development cycles
|
|
||||||
- traceable decisions
|
|
||||||
- minimal risk of unintended changes
|
|
||||||
- consistent logging of AI actions
|
|
||||||
|
|
||||||
This file complements:
|
|
||||||
|
|
||||||
- `AGENT.md`
|
|
||||||
- `instructions-agent.md`
|
|
||||||
- `docs/ai-worklog.md`
|
|
||||||
- `docs/ai-prompts.md`
|
|
||||||
- `docs/ai-execution-log.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Standard Workflow
|
|
||||||
|
|
||||||
Every AI task must follow this sequence:
|
|
||||||
|
|
||||||
PLAN → REVIEW → BUILD → VALIDATE → LOG
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Execution Order (Strict)
|
|
||||||
|
|
||||||
The following operational order must be respected when executing any BUILD task.
|
|
||||||
|
|
||||||
1. Implement changes (BUILD)
|
|
||||||
2. Run validation
|
|
||||||
make fix
|
|
||||||
make quality
|
|
||||||
3. Update AI logs
|
|
||||||
- docs/ai-execution-log.md
|
|
||||||
- docs/ai-prompts.md
|
|
||||||
- docs/ai-worklog.md
|
|
||||||
4. Stage all modified files including logs
|
|
||||||
5. Create commit
|
|
||||||
6. Push only if explicitly requested
|
|
||||||
|
|
||||||
Important rule:
|
|
||||||
Logs must always be written **before creating a commit**. If log files are modified after a commit, the task is considered incomplete and must be corrected.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
This sequence must not be skipped.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 1. PLAN
|
|
||||||
|
|
||||||
Goal:
|
|
||||||
Understand the problem and determine the minimal solution.
|
|
||||||
|
|
||||||
Actions:
|
|
||||||
|
|
||||||
- inspect repository state
|
|
||||||
- read relevant source files
|
|
||||||
- analyze existing architecture
|
|
||||||
- identify root cause of the problem
|
|
||||||
- propose a minimal change
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- PLAN must **not modify code**
|
|
||||||
- PLAN must **not change files**
|
|
||||||
- PLAN must only produce analysis and a proposal
|
|
||||||
|
|
||||||
Output should include:
|
|
||||||
|
|
||||||
- diagnosis summary
|
|
||||||
- proposed changes
|
|
||||||
- expected file scope
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 2. REVIEW
|
|
||||||
|
|
||||||
Goal:
|
|
||||||
Validate the plan before implementing it.
|
|
||||||
|
|
||||||
Actions:
|
|
||||||
|
|
||||||
- confirm the proposed scope is minimal
|
|
||||||
- ensure no unrelated components are affected
|
|
||||||
- verify the change aligns with project architecture
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- If the scope expands unexpectedly, return to PLAN.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 3. BUILD
|
|
||||||
|
|
||||||
Goal:
|
|
||||||
Implement the approved change.
|
|
||||||
|
|
||||||
Actions:
|
|
||||||
|
|
||||||
- modify only the files defined in scope
|
|
||||||
- preserve existing architecture
|
|
||||||
- avoid unnecessary refactors
|
|
||||||
- maintain compatibility with existing behavior
|
|
||||||
|
|
||||||
BUILD tasks may modify:
|
|
||||||
|
|
||||||
- source files
|
|
||||||
- tests
|
|
||||||
- documentation
|
|
||||||
|
|
||||||
BUILD tasks must remain minimal and targeted.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 4. VALIDATE
|
|
||||||
|
|
||||||
Goal:
|
|
||||||
Ensure the repository remains stable.
|
|
||||||
|
|
||||||
Agents must run:
|
|
||||||
|
|
||||||
```
|
|
||||||
make fix
|
|
||||||
make quality
|
|
||||||
```
|
|
||||||
|
|
||||||
Validation must pass before changes are considered complete.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 5. LOG
|
|
||||||
|
|
||||||
Goal:
|
|
||||||
Record the AI activity for traceability.
|
|
||||||
|
|
||||||
Three log files must be updated.
|
|
||||||
|
|
||||||
### Work Log
|
|
||||||
|
|
||||||
```
|
|
||||||
docs/ai-worklog.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Record:
|
|
||||||
|
|
||||||
- AI BUILD ENTRY
|
|
||||||
- or AI PLAN ENTRY
|
|
||||||
|
|
||||||
Including:
|
|
||||||
|
|
||||||
- Date (YYYY-MM-DD HH:MM)
|
|
||||||
- Task ID
|
|
||||||
- Agent
|
|
||||||
- Task
|
|
||||||
- Objective
|
|
||||||
- Scope
|
|
||||||
- Files Modified / Files Inspected
|
|
||||||
- Key Decisions / Proposed Changes
|
|
||||||
- Validation
|
|
||||||
- Result
|
|
||||||
- Open Issues
|
|
||||||
|
|
||||||
### Prompt Log
|
|
||||||
|
|
||||||
```
|
|
||||||
docs/ai-prompts.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Record:
|
|
||||||
|
|
||||||
- Date (YYYY-MM-DD HH:MM)
|
|
||||||
- Task ID
|
|
||||||
- Agent
|
|
||||||
- Task
|
|
||||||
- prompt summary
|
|
||||||
- scope
|
|
||||||
- result summary
|
|
||||||
|
|
||||||
Do not store chain-of-thought reasoning.
|
|
||||||
|
|
||||||
### Execution Log
|
|
||||||
|
|
||||||
```
|
|
||||||
docs/ai-execution-log.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Record the full visible execution trace including:
|
|
||||||
|
|
||||||
- Date (YYYY-MM-DD HH:MM)
|
|
||||||
- Task ID
|
|
||||||
- Agent
|
|
||||||
- Task Type
|
|
||||||
- Full prompt text
|
|
||||||
- Agent todos
|
|
||||||
- Execution report
|
|
||||||
|
|
||||||
The execution log preserves the complete operational record of the AI session.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Task ID Coordination
|
|
||||||
|
|
||||||
Every PLAN or BUILD must generate a unique Task ID.
|
|
||||||
|
|
||||||
Format:
|
|
||||||
|
|
||||||
```
|
|
||||||
PLAN-YYYYMMDD-XXX
|
|
||||||
BUILD-YYYYMMDD-XXX
|
|
||||||
```
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```
|
|
||||||
PLAN-20260316-001
|
|
||||||
BUILD-20260316-002
|
|
||||||
```
|
|
||||||
|
|
||||||
The same Task ID must appear in:
|
|
||||||
|
|
||||||
- ai-execution-log.md
|
|
||||||
- ai-prompts.md
|
|
||||||
- ai-worklog.md
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Commit Workflow
|
|
||||||
|
|
||||||
Agents must **not commit or push automatically** unless explicitly instructed.
|
|
||||||
|
|
||||||
Before creating a commit the agent must ensure:
|
|
||||||
|
|
||||||
- validation has passed
|
|
||||||
- AI logs have been written
|
|
||||||
- docs/ai-execution-log.md, docs/ai-worklog.md and docs/ai-prompts.md are staged
|
|
||||||
|
|
||||||
Commits that omit required AI log entries violate the repository workflow rules.
|
|
||||||
|
|
||||||
When commits are requested:
|
|
||||||
|
|
||||||
1. Validate repository state
|
|
||||||
2. Stage modified files
|
|
||||||
3. Use structured commit messages
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```
|
|
||||||
feat: improve terminal navigation
|
|
||||||
|
|
||||||
- refine tree navigation
|
|
||||||
- improve scrolling
|
|
||||||
- update AI logs
|
|
||||||
```
|
|
||||||
|
|
||||||
Documentation-only updates should use:
|
|
||||||
|
|
||||||
```
|
|
||||||
docs:
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Failure Handling
|
|
||||||
|
|
||||||
If validation fails:
|
|
||||||
|
|
||||||
Agents must:
|
|
||||||
|
|
||||||
1. stop the BUILD process
|
|
||||||
2. report the error
|
|
||||||
3. propose a fix
|
|
||||||
|
|
||||||
Agents must **not silently bypass failing checks**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Scope Control
|
|
||||||
|
|
||||||
Agents must strictly respect the defined scope.
|
|
||||||
|
|
||||||
If additional changes appear necessary:
|
|
||||||
|
|
||||||
- return to PLAN
|
|
||||||
- propose a new change
|
|
||||||
|
|
||||||
Do not expand scope during BUILD.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# End of Document
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
# AI Task Template
|
|
||||||
|
|
||||||
Reusable prompt template for launching tasks with AI agents.
|
|
||||||
|
|
||||||
This template enforces the repository workflow defined in:
|
|
||||||
|
|
||||||
- AGENT.md
|
|
||||||
- AGENT_WORKFLOW.md
|
|
||||||
- instructions-agent.md
|
|
||||||
|
|
||||||
It ensures every task follows the deterministic sequence:
|
|
||||||
|
|
||||||
PLAN → REVIEW → BUILD → VALIDATE → LOG
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Basic Task Structure
|
|
||||||
|
|
||||||
Use the following structure when requesting work from an AI agent.
|
|
||||||
|
|
||||||
```
|
|
||||||
Task Type:
|
|
||||||
PLAN | BUILD
|
|
||||||
|
|
||||||
Task ID:
|
|
||||||
PLAN-YYYYMMDD-XXX | BUILD-YYYYMMDD-XXX
|
|
||||||
|
|
||||||
Date:
|
|
||||||
YYYY-MM-DD HH:MM
|
|
||||||
|
|
||||||
Objective:
|
|
||||||
Short description of the goal.
|
|
||||||
|
|
||||||
Context:
|
|
||||||
Relevant background about the current repository state.
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
List of files or directories the agent may modify or inspect.
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
Detailed description of the behavior that must be implemented or analyzed.
|
|
||||||
|
|
||||||
Constraints:
|
|
||||||
Things the agent must NOT change or must preserve.
|
|
||||||
|
|
||||||
Testing:
|
|
||||||
Describe expected tests or validations.
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
The agent must run:
|
|
||||||
|
|
||||||
make fix
|
|
||||||
make quality
|
|
||||||
|
|
||||||
Logging requirements:
|
|
||||||
- Append an entry to docs/ai-worklog.md
|
|
||||||
- Append an entry to docs/ai-prompts.md
|
|
||||||
- Append an entry to docs/ai-execution-log.md
|
|
||||||
- Include Date, Task ID, Agent, Scope, Result
|
|
||||||
|
|
||||||
Execution Logging requirements:
|
|
||||||
- Append an entry to docs/ai-execution-log.md
|
|
||||||
- Include:
|
|
||||||
- full prompt
|
|
||||||
- agent todos
|
|
||||||
- final execution report
|
|
||||||
- Use the standard execution log format defined in docs/ai-execution-log.md
|
|
||||||
|
|
||||||
Git requirements:
|
|
||||||
- Do NOT commit or push unless explicitly requested.
|
|
||||||
|
|
||||||
Expected Report:
|
|
||||||
The agent must summarize:
|
|
||||||
- files changed
|
|
||||||
- behavior changes
|
|
||||||
- validation results
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Example BUILD Task
|
|
||||||
|
|
||||||
```
|
|
||||||
Task Type:
|
|
||||||
BUILD
|
|
||||||
|
|
||||||
Task ID:
|
|
||||||
BUILD-YYYYMMDD-XXX
|
|
||||||
|
|
||||||
Date:
|
|
||||||
YYYY-MM-DD HH:MM
|
|
||||||
|
|
||||||
Objective:
|
|
||||||
Improve vertical scrolling behavior in the terminal UI.
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- src/app/ui.py
|
|
||||||
- tests/test_ui.py
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
Ensure the selected entry remains visible when navigating through long directory lists.
|
|
||||||
|
|
||||||
Constraints:
|
|
||||||
Do not modify navigator logic.
|
|
||||||
|
|
||||||
Testing:
|
|
||||||
Add helper tests if necessary.
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
make fix
|
|
||||||
make quality
|
|
||||||
|
|
||||||
Logging requirements:
|
|
||||||
- Update `docs/ai-worklog.md` and `docs/ai-prompts.md` using the standard log format.
|
|
||||||
|
|
||||||
Execution Logging requirements:
|
|
||||||
- Update docs/ai-execution-log.md with:
|
|
||||||
- full prompt
|
|
||||||
- agent todos
|
|
||||||
- final execution report
|
|
||||||
|
|
||||||
Git requirements:
|
|
||||||
- Do NOT commit or push unless explicitly requested.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Example PLAN Task
|
|
||||||
|
|
||||||
```
|
|
||||||
Task Type:
|
|
||||||
PLAN
|
|
||||||
|
|
||||||
Task ID:
|
|
||||||
PLAN-YYYYMMDD-XXX
|
|
||||||
|
|
||||||
Date:
|
|
||||||
YYYY-MM-DD HH:MM
|
|
||||||
|
|
||||||
Objective:
|
|
||||||
Investigate inconsistent navigation behavior.
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- src/app/ui.py
|
|
||||||
- src/app/navigator.py
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
Analyze the current navigation flow and propose a minimal fix.
|
|
||||||
|
|
||||||
Constraints:
|
|
||||||
Do not modify code.
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
make fix
|
|
||||||
make quality
|
|
||||||
|
|
||||||
Logging requirements:
|
|
||||||
- Update `docs/ai-worklog.md` and `docs/ai-prompts.md` using the standard log format.
|
|
||||||
|
|
||||||
Execution Logging requirements:
|
|
||||||
- Update docs/ai-execution-log.md with:
|
|
||||||
- full prompt
|
|
||||||
- agent todos
|
|
||||||
- final execution report
|
|
||||||
|
|
||||||
Git requirements:
|
|
||||||
- Do NOT commit or push unless explicitly requested.
|
|
||||||
|
|
||||||
Expected Output:
|
|
||||||
- diagnosis summary
|
|
||||||
- proposed changes
|
|
||||||
- expected file scope
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Notes
|
|
||||||
|
|
||||||
This template keeps AI interactions consistent across tasks and helps maintain a clean development history.
|
|
||||||
74
CHANGELOG.md
74
CHANGELOG.md
@@ -4,16 +4,68 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on Keep a Changelog, and this project follows Semantic Versioning.
|
The format is based on Keep a Changelog, and this project follows Semantic Versioning.
|
||||||
|
|
||||||
## [1.0.0] - 2026-03-14
|
## [0.2.6] - 2026-04-04
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Initial release of the Python AI Dev Template
|
- Incremental filter workflow (`/`) with contextual Enter behavior:
|
||||||
- Universal Python project structure using `src/`
|
- Directories navigate in-place
|
||||||
- `pyproject.toml`-based project configuration
|
- Files open the app menu
|
||||||
- Development tooling with `black`, `ruff`, `mypy`, and `pytest`
|
- File visibility toggle (`a`) to switch between directories-only and mixed view
|
||||||
- `pre-commit` integration
|
- Open-with app menu (`o`) with arrow-key selection (`↑`/`↓`, `Enter`, `Esc`)
|
||||||
- `Makefile` with development, quality, and diagnostic commands
|
- New opener support for `nano`, `less`, and `bat` for file actions
|
||||||
- `instructions-agent.md` for AI coding agent guidance
|
- Blocking terminal app execution with UI state restore after exit
|
||||||
- `.env.example` for environment-based configuration
|
|
||||||
- README with setup, workflow, and template reuse instructions
|
### Improved
|
||||||
- MIT license
|
- Open-with options are now context-aware (GUI/headless, file/directory) and only shown when executables are available
|
||||||
|
- Better handling of paths containing spaces when launching openers
|
||||||
|
- Extended README with full controls reference and platform compatibility guidance
|
||||||
|
|
||||||
|
## [0.2.5] - 2026-04-03
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Add explicit repository link in README for PyPI users to access the full demo and documentation
|
||||||
|
|
||||||
|
## [0.2.4] - 2026-04-03
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Use absolute demo GIF URL in README so image renders correctly on PyPI
|
||||||
|
|
||||||
|
## [0.2.3] - 2026-04-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Add project demo GIF at `docs/demo.gif`
|
||||||
|
- Show GIF demo in README
|
||||||
|
|
||||||
|
## [0.2.2] - 2026-04-02
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Include `cd_browser_post_install.py` in wheel/sdist so `cd_browser_post_install` works after `pip install`
|
||||||
|
|
||||||
|
## [0.2.1] - 2026-04-02
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Prevent crashes when scanning directories with restricted permissions
|
||||||
|
- Handle non-interactive post-install runs without failing on EOF
|
||||||
|
- Fix shell reload message interpolation in post-install output
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Make post-install shell integration idempotent (avoid duplicate `cd_()` entries)
|
||||||
|
- Add test coverage for permission errors and post-install flows
|
||||||
|
- Update default application name to `cd-browser`
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-04-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Interactive post-install script (`cd_browser_post_install`) for automatic shell integration
|
||||||
|
- Option to automatically add `cd_()` function to ~/.bashrc or ~/.zshrc during installation
|
||||||
|
- Improved uninstall instructions in README
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Installation UX: clearer setup process with user prompts
|
||||||
|
- Shell integration: better guidance for enabling `cd_` command
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial release of **cd-browser**
|
||||||
|
- Terminal-based directory navigation
|
||||||
|
- Interactive directory tree navigation
|
||||||
|
- History navigation mode
|
||||||
|
|||||||
126
README.md
126
README.md
@@ -1,46 +1,119 @@
|
|||||||
# cd-browser
|
# cd-browser
|
||||||
|
|
||||||
`cd-browser` is a terminal-native directory navigator for fast filesystem browsing from the command line.
|
**Stop typing paths. Browse them.**
|
||||||
|
|
||||||
It provides an interactive interface for exploring directories with the keyboard and returns the selected path at the end of the session so shell wrappers can change the current shell directory.
|
`cd-browser` is a fast keyboard-driven directory navigator for the terminal.
|
||||||
|
It lets you explore directory trees visually and jump to any folder instantly.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Repository (full demo + docs): https://gitea.sakydogalo.es/saky/cd-browser
|
||||||
|
|
||||||
|
## Why cd-browser?
|
||||||
|
|
||||||
|
Working in the terminal often means:
|
||||||
|
|
||||||
|
- typing long directory paths
|
||||||
|
- navigating deep folder trees
|
||||||
|
- repeating `cd ..` multiple times
|
||||||
|
|
||||||
|
`cd-browser` provides an **interactive terminal UI** that allows you to browse directories with the keyboard and return the selected path directly to your shell.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- ⌨️ Fully keyboard-driven workflow (`↑` / `↓` / `←` / `→`)
|
||||||
|
- 🌲 Expand and collapse directories (`→` to expand/enter, `←` to collapse/back)
|
||||||
|
- 📜 Navigation history inside the session (`h` history mode, `b` back, `f` forward)
|
||||||
|
- 🔎 Incremental filter mode (`/`, type to filter, `Backspace`, `Esc`)
|
||||||
|
- 📄 Toggle file visibility (`a`) to switch between directories-only and mixed view
|
||||||
|
- ⚡ Fast jumps in large lists (`PgUp`/`PgDn`, `Home`/`End`, `g`/`G` in normal mode)
|
||||||
|
- 🚀 Contextual `open with` menu (`o`) with app detection based on your environment
|
||||||
|
- 🔁 Blocking terminal editors restore the UI state when they exit (`nvim`, `nano`, `less`, `bat`)
|
||||||
|
- 👀 Toggle hidden entries with `.`
|
||||||
|
- 🖥 Native terminal interface
|
||||||
|
- 🔁 Works with Bash, Zsh and other shells
|
||||||
|
- ⚡ Returns the selected path to the shell (`Enter` to confirm, `Esc` to cancel)
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
Normal mode:
|
||||||
|
|
||||||
|
- `↑` / `↓`: Move selection
|
||||||
|
- `→`: Expand directory, or enter when already expanded
|
||||||
|
- `←`: Collapse directory, or go to parent
|
||||||
|
- `Enter`: Confirm selected path and exit
|
||||||
|
- `Esc`: Cancel and return the original path
|
||||||
|
- `h`: Toggle history mode
|
||||||
|
- `b` / `f`: Back / forward in navigation history
|
||||||
|
- `.`: Toggle hidden entries
|
||||||
|
- `a`: Toggle file visibility
|
||||||
|
- `/`: Enter filter mode
|
||||||
|
- `o`: Open selected entry with an app
|
||||||
|
- `PgUp` / `PgDn`: Page jump
|
||||||
|
- `Home` / `End`: Jump to first/last entry
|
||||||
|
- `g` / `G`: Jump to first/last entry
|
||||||
|
|
||||||
|
Filter mode:
|
||||||
|
|
||||||
|
- Type text: Filter visible entries in real time (case-insensitive)
|
||||||
|
- `↑` / `↓`: Move inside filtered results
|
||||||
|
- `Enter`: Apply filter and perform contextual action:
|
||||||
|
- Directory: navigate into it without exiting `cd_`
|
||||||
|
- File: open app selection menu
|
||||||
|
- `Esc`: Exit filter mode and clear query
|
||||||
|
- `Backspace`: Remove last filter character
|
||||||
|
|
||||||
|
History mode:
|
||||||
|
|
||||||
|
- `↑` / `↓`: Move through history entries
|
||||||
|
- `Enter`: Jump to selected history path
|
||||||
|
- `Esc` or `h`: Exit history mode
|
||||||
|
- `PgUp` / `PgDn`, `Home` / `End`: Fast history navigation
|
||||||
|
|
||||||
|
Open with menu (`o`):
|
||||||
|
|
||||||
|
- `↑` / `↓`: Move between app options
|
||||||
|
- `Enter`: Launch selected app
|
||||||
|
- `Esc`: Cancel
|
||||||
|
|
||||||
|
The menu only shows apps available in your system (`PATH`) and context:
|
||||||
|
|
||||||
|
- GUI options (when GUI is available): `code`, system `open`/`xdg-open`
|
||||||
|
- Terminal options: `antigravity`, `nvim`, `nano`, `less`, `bat` (as applicable)
|
||||||
|
|
||||||
|
## Quick Demo
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd_
|
||||||
|
```
|
||||||
|
|
||||||
|
Browse directories using the arrow keys and press **Enter** to jump directly to the selected folder.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
See the documentation index:
|
See the documentation index:
|
||||||
|
|
||||||
|
```
|
||||||
docs/index.md
|
docs/index.md
|
||||||
|
```
|
||||||
## Features
|
|
||||||
|
|
||||||
- Interactive terminal directory browser
|
|
||||||
- Keyboard-driven navigation
|
|
||||||
- Parent directory entry via `..`
|
|
||||||
- Expand and collapse directory trees
|
|
||||||
- Session navigation history support in the application state
|
|
||||||
- Installable CLI command: `cd_browser`
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Install the project in user mode:
|
Install the project in user mode:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install .
|
pip install cd-browser
|
||||||
```
|
```
|
||||||
|
|
||||||
For editable local development:
|
**Important**: After installation, run this to set up the `cd_` command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -e '.[dev]'
|
cd_browser_post_install
|
||||||
```
|
```
|
||||||
|
|
||||||
After installation, the application command is:
|
This interactive script will guide you through enabling `cd_` in your shell.
|
||||||
|
|
||||||
```bash
|
|
||||||
cd_browser
|
|
||||||
```
|
|
||||||
|
|
||||||
When the interactive session exits, the program prints the final selected directory path.
|
|
||||||
|
|
||||||
## Shell Integration For `cd_`
|
## Shell Integration For `cd_`
|
||||||
|
|
||||||
@@ -77,6 +150,15 @@ Then use:
|
|||||||
cd_
|
cd_
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- Inside `cd_browser`, press `.` to toggle hidden entries.
|
||||||
|
|
||||||
|
## Platform Compatibility
|
||||||
|
|
||||||
|
Practical conclusion:
|
||||||
|
|
||||||
|
- Best current support: **macOS and Linux**
|
||||||
|
- **Windows is partially supported**, but still needs real-world validation in Windows terminal environments before being considered fully supported
|
||||||
|
|
||||||
## Uninstall
|
## Uninstall
|
||||||
|
|
||||||
If the project was installed with `pip`, remove it with:
|
If the project was installed with `pip`, remove it with:
|
||||||
@@ -85,7 +167,7 @@ If the project was installed with `pip`, remove it with:
|
|||||||
pip uninstall cd-browser
|
pip uninstall cd-browser
|
||||||
```
|
```
|
||||||
|
|
||||||
If you also added the `cd_` shell wrapper, remove that function from your shell profile and reload the shell configuration.
|
**Important**: After uninstalling, remove the `cd_()` function from your shell profile (~/.bashrc or ~/.zshrc) to clean up completely.
|
||||||
|
|
||||||
## Developer Setup
|
## Developer Setup
|
||||||
|
|
||||||
|
|||||||
BIN
docs/demo.gif
Normal file
BIN
docs/demo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 469 KiB |
@@ -1,229 +0,0 @@
|
|||||||
# 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.
|
|
||||||
|
|
||||||
## AI Worklog Policy
|
|
||||||
|
|
||||||
This repository maintains an AI-assisted development log.
|
|
||||||
|
|
||||||
Files:
|
|
||||||
- `docs/ai-worklog.md`
|
|
||||||
- `docs/ai-prompts.md`
|
|
||||||
- `docs/ai-execution-log.md`
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
1. After every significant `plan` or `build` task, append a concise worklog entry to `docs/ai-worklog.md`.
|
|
||||||
2. Each worklog entry must include:
|
|
||||||
- date
|
|
||||||
- task type (`plan` or `build`)
|
|
||||||
- short objective
|
|
||||||
- files inspected or modified
|
|
||||||
- key decisions
|
|
||||||
- validation commands run
|
|
||||||
- result
|
|
||||||
- unresolved issues if any
|
|
||||||
3. When a prompt meaningfully changes architecture, behavior, workflow, debugging direction, or project structure, append the prompt (or a concise cleaned version of it) to `docs/ai-prompts.md`.
|
|
||||||
4. Do not store private chain-of-thought or internal reasoning.
|
|
||||||
5. Store only concise, user-facing summaries of what was done.
|
|
||||||
6. Keep entries chronological and easy to scan.
|
|
||||||
|
|
||||||
## Mandatory AI Logging
|
|
||||||
|
|
||||||
Every significant AI interaction in this repository must be logged.
|
|
||||||
|
|
||||||
This applies to:
|
|
||||||
- `plan` tasks
|
|
||||||
- `build` tasks
|
|
||||||
- debugging tasks
|
|
||||||
- documentation tasks
|
|
||||||
- architectural discussions that affect the project
|
|
||||||
|
|
||||||
Required files:
|
|
||||||
- `docs/ai-worklog.md`
|
|
||||||
- `docs/ai-prompts.md`
|
|
||||||
- `docs/ai-execution-log.md`
|
|
||||||
|
|
||||||
Mandatory rules:
|
|
||||||
1. After every significant `plan` task, append a concise entry to `docs/ai-worklog.md`.
|
|
||||||
2. After every significant `build` task, append a concise entry to `docs/ai-worklog.md`.
|
|
||||||
3. If the task was driven by a meaningful prompt, also append an entry to `docs/ai-prompts.md`.
|
|
||||||
4. The agent must append a full execution entry to `docs/ai-execution-log.md` including:
|
|
||||||
- the full prompt provided to the agent
|
|
||||||
- the agent todo list or execution plan
|
|
||||||
- the final execution report produced by the agent
|
|
||||||
5. Logging is part of the task itself and must not be skipped.
|
|
||||||
6. The task is not complete until the logs are updated.
|
|
||||||
7. Logs must always be written **before creating a commit**.
|
|
||||||
8. The agent must ensure that the following files are updated and staged before any commit:
|
|
||||||
- `docs/ai-worklog.md`
|
|
||||||
- `docs/ai-prompts.md`
|
|
||||||
- `docs/ai-execution-log.md`
|
|
||||||
9. If a commit is created without the corresponding log entries, the task is considered incomplete and must be corrected.
|
|
||||||
10. Log entries must be part of the same commit whenever possible to maintain traceability between the code change and the AI task that produced it.
|
|
||||||
|
|
||||||
Each worklog entry must include:
|
|
||||||
- date
|
|
||||||
- task type (`plan` or `build`)
|
|
||||||
- short objective
|
|
||||||
- files inspected or modified
|
|
||||||
- key decisions
|
|
||||||
- validation commands run, if any
|
|
||||||
- result
|
|
||||||
- unresolved issues, if any
|
|
||||||
|
|
||||||
Validation logging rule:
|
|
||||||
- If validation commands such as `make fix`, `make quality`, `pytest`, or other checks are executed during the task, the worklog entry must record them explicitly.
|
|
||||||
- The value `validation: not run` may only be used if no validation commands were executed.
|
|
||||||
- The worklog entry must reflect the actual commands run during the task.
|
|
||||||
|
|
||||||
|
|
||||||
Each prompt log entry must include:
|
|
||||||
- date
|
|
||||||
- task type (`plan` or `build`)
|
|
||||||
- short prompt summary
|
|
||||||
- scope
|
|
||||||
- result summary
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Do not store chain-of-thought or private reasoning.
|
|
||||||
- Store only concise user-facing summaries.
|
|
||||||
- Keep entries chronological.
|
|
||||||
- Even small but meaningful tasks must be logged.
|
|
||||||
|
|
||||||
Execution log entries must include:
|
|
||||||
- date
|
|
||||||
- task ID
|
|
||||||
- task type (`plan` or `build`)
|
|
||||||
- full prompt text
|
|
||||||
- agent todos
|
|
||||||
- execution report
|
|
||||||
|
|
||||||
## Standard AI Log Entry Format
|
|
||||||
|
|
||||||
To ensure consistency and readability of long AI-assisted development histories, all log entries must follow a standardized block format.
|
|
||||||
|
|
||||||
Agents must write log entries using the following structure.
|
|
||||||
|
|
||||||
Example for build tasks:
|
|
||||||
|
|
||||||
### AI BUILD ENTRY
|
|
||||||
Date: YYYY-MM-DD HH:MM
|
|
||||||
Task ID: BUILD-YYYYMMDD-XXX
|
|
||||||
Agent: OpenCode
|
|
||||||
Task: build
|
|
||||||
Objective: short description of the task
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- src/app/module.py
|
|
||||||
- tests/test_module.py
|
|
||||||
|
|
||||||
Files Modified:
|
|
||||||
- file/path/example.py
|
|
||||||
- another/file.md
|
|
||||||
|
|
||||||
Key Decisions:
|
|
||||||
- short bullet explaining important choices
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
- make fix (passed)
|
|
||||||
- make quality (passed)
|
|
||||||
|
|
||||||
Result:
|
|
||||||
- short description of what changed or was achieved
|
|
||||||
|
|
||||||
Open Issues:
|
|
||||||
- optional list of unresolved problems
|
|
||||||
|
|
||||||
Example for plan tasks:
|
|
||||||
|
|
||||||
### AI PLAN ENTRY
|
|
||||||
Date: YYYY-MM-DD HH:MM
|
|
||||||
Task ID: PLAN-YYYYMMDD-XXX
|
|
||||||
Agent: OpenCode
|
|
||||||
Task: plan
|
|
||||||
Objective: short description of planning objective
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- src/app/module.py
|
|
||||||
- specs/module_spec.md
|
|
||||||
|
|
||||||
Files Inspected:
|
|
||||||
- src/app/example.py
|
|
||||||
- specs/example_spec.md
|
|
||||||
|
|
||||||
Proposed Changes:
|
|
||||||
- summary of planned changes
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- optional relevant observations
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Always use these headers exactly (`AI BUILD ENTRY` or `AI PLAN ENTRY`).
|
|
||||||
- Keep entries concise and structured.
|
|
||||||
- Do not include chain-of-thought reasoning.
|
|
||||||
- Use bullet points when possible.
|
|
||||||
- This format must be used when updating `docs/ai-worklog.md`.
|
|
||||||
- The Date field must include both date and time using the format YYYY-MM-DD HH:MM.
|
|
||||||
- Every log entry must include a `Task ID`.
|
|
||||||
- `Task ID` format must be `BUILD-YYYYMMDD-XXX` or `PLAN-YYYYMMDD-XXX`.
|
|
||||||
- `Agent` must identify the tool or AI system performing the task (e.g., OpenCode).
|
|
||||||
- `Scope` must describe the main files or project areas affected.
|
|
||||||
- These fields make the AI worklog suitable for long-term traceability and large histories.
|
|
||||||
@@ -4,17 +4,31 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "cd-browser"
|
name = "cd-browser"
|
||||||
version = "0.1.0"
|
version = "0.2.6"
|
||||||
description = "A universal and adaptable Python project template for AI-assisted development."
|
description = "A fast keyboard-driven directory navigator for the terminal."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.8"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Saky" }
|
{ name = "Saky" }
|
||||||
]
|
]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.8",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Topic :: Utilities",
|
||||||
|
]
|
||||||
dependencies = []
|
dependencies = []
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
cd_browser = "app.main:main"
|
cd_browser = "app.main:main"
|
||||||
|
cd_browser_post_install = "cd_browser_post_install:main"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
@@ -27,6 +41,7 @@ dev = [
|
|||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
package-dir = {"" = "src"}
|
package-dir = {"" = "src"}
|
||||||
|
py-modules = ["cd_browser_post_install"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ from dataclasses import dataclass
|
|||||||
class AppConfig:
|
class AppConfig:
|
||||||
"""Application configuration."""
|
"""Application configuration."""
|
||||||
|
|
||||||
app_name: str = "Python AI Dev Template"
|
app_name: str = "cd-browser"
|
||||||
debug: bool = False
|
debug: bool = False
|
||||||
|
|||||||
@@ -13,28 +13,95 @@ class DirectoryEntry:
|
|||||||
has_children: bool
|
has_children: bool
|
||||||
|
|
||||||
|
|
||||||
def list_directories(path: Path) -> list[DirectoryEntry]:
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FileSystemEntry:
|
||||||
|
"""Filesystem entry metadata for tree rendering."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
path: Path
|
||||||
|
is_directory: bool
|
||||||
|
has_children: bool
|
||||||
|
|
||||||
|
|
||||||
|
def list_directories(path: Path, show_hidden: bool = False) -> list[DirectoryEntry]:
|
||||||
"""Return direct child directories sorted by name."""
|
"""Return direct child directories sorted by name."""
|
||||||
|
|
||||||
|
entries = list_entries(path, show_hidden=show_hidden, include_files=False)
|
||||||
|
return [
|
||||||
|
DirectoryEntry(
|
||||||
|
name=entry.name,
|
||||||
|
path=entry.path,
|
||||||
|
has_children=entry.has_children,
|
||||||
|
)
|
||||||
|
for entry in entries
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def list_entries(
|
||||||
|
path: Path, show_hidden: bool = False, include_files: bool = False
|
||||||
|
) -> list[FileSystemEntry]:
|
||||||
|
"""Return direct child entries sorted by type and name."""
|
||||||
|
|
||||||
directory_path = path.expanduser().resolve()
|
directory_path = path.expanduser().resolve()
|
||||||
entries: list[DirectoryEntry] = []
|
entries: list[FileSystemEntry] = []
|
||||||
|
|
||||||
for child in directory_path.iterdir():
|
try:
|
||||||
if child.is_dir():
|
children = list(directory_path.iterdir())
|
||||||
entries.append(
|
except PermissionError:
|
||||||
DirectoryEntry(
|
return []
|
||||||
name=child.name,
|
|
||||||
path=child,
|
for child in children:
|
||||||
has_children=has_subdirectories(child),
|
try:
|
||||||
)
|
is_directory = child.is_dir()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not is_directory and not include_files:
|
||||||
|
continue
|
||||||
|
if not show_hidden and child.name.startswith("."):
|
||||||
|
continue
|
||||||
|
|
||||||
|
entries.append(
|
||||||
|
FileSystemEntry(
|
||||||
|
name=child.name,
|
||||||
|
path=child,
|
||||||
|
is_directory=is_directory,
|
||||||
|
has_children=(
|
||||||
|
has_subdirectories(child, show_hidden=show_hidden)
|
||||||
|
if is_directory
|
||||||
|
else False
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return sorted(entries, key=lambda entry: entry.name.casefold())
|
return sorted(
|
||||||
|
entries,
|
||||||
|
key=lambda entry: (not entry.is_directory, entry.name.casefold()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def has_subdirectories(path: Path) -> bool:
|
def has_subdirectories(path: Path, show_hidden: bool = False) -> bool:
|
||||||
"""Return whether the directory contains at least one subdirectory."""
|
"""Return whether the directory contains at least one subdirectory."""
|
||||||
|
|
||||||
directory_path = path.expanduser().resolve()
|
directory_path = path.expanduser().resolve()
|
||||||
|
|
||||||
return any(child.is_dir() for child in directory_path.iterdir())
|
try:
|
||||||
|
children = list(directory_path.iterdir())
|
||||||
|
except PermissionError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for child in children:
|
||||||
|
try:
|
||||||
|
is_directory = child.is_dir()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not is_directory:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not show_hidden and child.name.startswith("."):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.filesystem import list_directories
|
from app.filesystem import list_entries
|
||||||
from app.history import SessionHistory
|
from app.history import SessionHistory
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class VisibleEntry:
|
class VisibleEntry:
|
||||||
"""A directory row visible in the navigator."""
|
"""A row visible in the navigator tree."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
path: Path
|
path: Path
|
||||||
@@ -17,6 +17,7 @@ class VisibleEntry:
|
|||||||
has_children: bool
|
has_children: bool
|
||||||
is_expanded: bool
|
is_expanded: bool
|
||||||
is_parent: bool = False
|
is_parent: bool = False
|
||||||
|
is_file: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -28,6 +29,8 @@ class Navigator:
|
|||||||
current_path: Path = field(init=False)
|
current_path: Path = field(init=False)
|
||||||
selected_index: int = field(init=False, default=0)
|
selected_index: int = field(init=False, default=0)
|
||||||
_expanded_paths: set[Path] = field(init=False, default_factory=set)
|
_expanded_paths: set[Path] = field(init=False, default_factory=set)
|
||||||
|
show_hidden: bool = False
|
||||||
|
show_files: bool = False
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
self.current_path = self.start_path.expanduser().resolve()
|
self.current_path = self.start_path.expanduser().resolve()
|
||||||
@@ -68,6 +71,9 @@ class Navigator:
|
|||||||
def enter_selected_directory(self) -> Path:
|
def enter_selected_directory(self) -> Path:
|
||||||
"""Enter the currently selected directory and record it in history."""
|
"""Enter the currently selected directory and record it in history."""
|
||||||
|
|
||||||
|
if self.selected_entry.is_file:
|
||||||
|
return self.current_path
|
||||||
|
|
||||||
return self._change_directory(self.selected_entry.path)
|
return self._change_directory(self.selected_entry.path)
|
||||||
|
|
||||||
def go_to_parent_directory(self) -> Path:
|
def go_to_parent_directory(self) -> Path:
|
||||||
@@ -144,6 +150,22 @@ class Navigator:
|
|||||||
self._set_current_path(path)
|
self._set_current_path(path)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
def toggle_hidden(self) -> None:
|
||||||
|
"""Toggle showing/hiding hidden directory entries."""
|
||||||
|
|
||||||
|
self.show_hidden = not self.show_hidden
|
||||||
|
self._reset_view_state()
|
||||||
|
|
||||||
|
def toggle_files(self) -> None:
|
||||||
|
"""Toggle showing/hiding file entries."""
|
||||||
|
|
||||||
|
self.show_files = not self.show_files
|
||||||
|
self._reset_view_state()
|
||||||
|
|
||||||
|
def _reset_view_state(self) -> None:
|
||||||
|
self.selected_index = 0
|
||||||
|
self._expanded_paths.clear()
|
||||||
|
|
||||||
def select_history_entry(self, index: int) -> Path:
|
def select_history_entry(self, index: int) -> Path:
|
||||||
"""Jump to a directory stored in session history."""
|
"""Jump to a directory stored in session history."""
|
||||||
|
|
||||||
@@ -154,20 +176,25 @@ class Navigator:
|
|||||||
def _build_entries(self, path: Path, depth: int) -> list[VisibleEntry]:
|
def _build_entries(self, path: Path, depth: int) -> list[VisibleEntry]:
|
||||||
entries: list[VisibleEntry] = []
|
entries: list[VisibleEntry] = []
|
||||||
|
|
||||||
for directory in list_directories(path):
|
for item in list_entries(
|
||||||
is_expanded = directory.path in self._expanded_paths
|
path, show_hidden=self.show_hidden, include_files=self.show_files
|
||||||
|
):
|
||||||
|
is_expanded = (
|
||||||
|
item.path in self._expanded_paths if item.is_directory else False
|
||||||
|
)
|
||||||
entries.append(
|
entries.append(
|
||||||
VisibleEntry(
|
VisibleEntry(
|
||||||
name=directory.name,
|
name=item.name,
|
||||||
path=directory.path,
|
path=item.path,
|
||||||
depth=depth,
|
depth=depth,
|
||||||
has_children=directory.has_children,
|
has_children=item.has_children,
|
||||||
is_expanded=is_expanded,
|
is_expanded=is_expanded,
|
||||||
|
is_file=not item.is_directory,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if is_expanded:
|
if is_expanded:
|
||||||
entries.extend(self._build_entries(directory.path, depth=depth + 1))
|
entries.extend(self._build_entries(item.path, depth=depth + 1))
|
||||||
|
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|||||||
189
src/app/opener.py
Normal file
189
src/app/opener.py
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
LaunchMode = Literal["background", "blocking"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OpenWithOption:
|
||||||
|
"""Candidate application command for opening a path."""
|
||||||
|
|
||||||
|
label: str
|
||||||
|
command_template: str
|
||||||
|
executable: str
|
||||||
|
launch_mode: LaunchMode
|
||||||
|
requires_gui: bool = False
|
||||||
|
supports_files: bool = True
|
||||||
|
supports_directories: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
def available_openers(path: Path) -> list[OpenWithOption]:
|
||||||
|
"""Return available open-with options for a file or directory path."""
|
||||||
|
|
||||||
|
is_directory = path.is_dir()
|
||||||
|
has_gui = _has_gui_session()
|
||||||
|
options: list[OpenWithOption] = []
|
||||||
|
|
||||||
|
for option in _default_options():
|
||||||
|
if is_directory and not option.supports_directories:
|
||||||
|
continue
|
||||||
|
if not is_directory and not option.supports_files:
|
||||||
|
continue
|
||||||
|
if option.requires_gui and not has_gui:
|
||||||
|
continue
|
||||||
|
if not shutil.which(option.executable):
|
||||||
|
continue
|
||||||
|
|
||||||
|
options.append(option)
|
||||||
|
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def launch_background_option(path: Path, option: OpenWithOption) -> tuple[bool, str]:
|
||||||
|
"""Launch a non-blocking option and return status/message."""
|
||||||
|
|
||||||
|
command = _command_parts(option.command_template, path)
|
||||||
|
if not command:
|
||||||
|
return False, "Invalid open command"
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
except OSError as error:
|
||||||
|
return False, f"Open failed: {error}"
|
||||||
|
|
||||||
|
return True, f"Opened with {option.label}"
|
||||||
|
|
||||||
|
|
||||||
|
def launch_blocking_option(path: Path, option: OpenWithOption) -> tuple[bool, str]:
|
||||||
|
"""Launch a blocking terminal option and return status/message."""
|
||||||
|
|
||||||
|
command = _command_parts(option.command_template, path)
|
||||||
|
if not command:
|
||||||
|
return False, "Invalid open command"
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(command, check=False)
|
||||||
|
except OSError as error:
|
||||||
|
return False, f"Open failed: {error}"
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
f"Open failed: {option.label} exited with code {result.returncode}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return True, f"Opened with {option.label}"
|
||||||
|
|
||||||
|
|
||||||
|
def is_blocking_option(option: OpenWithOption) -> bool:
|
||||||
|
"""Return whether the option should block the current terminal session."""
|
||||||
|
|
||||||
|
return option.launch_mode == "blocking"
|
||||||
|
|
||||||
|
|
||||||
|
def _default_options() -> tuple[OpenWithOption, ...]:
|
||||||
|
return (
|
||||||
|
OpenWithOption(
|
||||||
|
label="VS Code",
|
||||||
|
command_template="code {path}",
|
||||||
|
executable="code",
|
||||||
|
launch_mode="background",
|
||||||
|
requires_gui=True,
|
||||||
|
),
|
||||||
|
OpenWithOption(
|
||||||
|
label="Open (system)",
|
||||||
|
command_template=_system_open_template(),
|
||||||
|
executable=_system_open_executable(),
|
||||||
|
launch_mode="background",
|
||||||
|
requires_gui=True,
|
||||||
|
supports_directories=False,
|
||||||
|
),
|
||||||
|
OpenWithOption(
|
||||||
|
label="Antigravity",
|
||||||
|
command_template="antigravity {path}",
|
||||||
|
executable="antigravity",
|
||||||
|
launch_mode="background",
|
||||||
|
),
|
||||||
|
OpenWithOption(
|
||||||
|
label="Neovim",
|
||||||
|
command_template="nvim {path}",
|
||||||
|
executable="nvim",
|
||||||
|
launch_mode="blocking",
|
||||||
|
),
|
||||||
|
OpenWithOption(
|
||||||
|
label="Nano",
|
||||||
|
command_template="nano {path}",
|
||||||
|
executable="nano",
|
||||||
|
launch_mode="blocking",
|
||||||
|
supports_directories=False,
|
||||||
|
),
|
||||||
|
OpenWithOption(
|
||||||
|
label="Less",
|
||||||
|
command_template="less {path}",
|
||||||
|
executable="less",
|
||||||
|
launch_mode="blocking",
|
||||||
|
supports_directories=False,
|
||||||
|
),
|
||||||
|
OpenWithOption(
|
||||||
|
label="Bat",
|
||||||
|
command_template="bat {path}",
|
||||||
|
executable="bat",
|
||||||
|
launch_mode="blocking",
|
||||||
|
supports_directories=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_gui_session() -> bool:
|
||||||
|
if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
return True
|
||||||
|
|
||||||
|
if sys.platform.startswith("linux"):
|
||||||
|
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
||||||
|
|
||||||
|
if sys.platform.startswith("win"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _system_open_template() -> str:
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
return "open {path}"
|
||||||
|
|
||||||
|
if sys.platform.startswith("linux"):
|
||||||
|
return "xdg-open {path}"
|
||||||
|
|
||||||
|
if sys.platform.startswith("win"):
|
||||||
|
return 'cmd /c start "" {path}'
|
||||||
|
|
||||||
|
return "open {path}"
|
||||||
|
|
||||||
|
|
||||||
|
def _system_open_executable() -> str:
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
return "open"
|
||||||
|
|
||||||
|
if sys.platform.startswith("linux"):
|
||||||
|
return "xdg-open"
|
||||||
|
|
||||||
|
if sys.platform.startswith("win"):
|
||||||
|
return "cmd"
|
||||||
|
|
||||||
|
return "open"
|
||||||
|
|
||||||
|
|
||||||
|
def _command_parts(command_template: str, path: Path) -> list[str]:
|
||||||
|
rendered = command_template.format(path=shlex.quote(str(path)))
|
||||||
|
return shlex.split(rendered)
|
||||||
480
src/app/ui.py
480
src/app/ui.py
@@ -10,8 +10,16 @@ from pathlib import Path
|
|||||||
from typing import TextIO
|
from typing import TextIO
|
||||||
|
|
||||||
from app.navigator import Navigator, VisibleEntry
|
from app.navigator import Navigator, VisibleEntry
|
||||||
|
from app.opener import (
|
||||||
|
OpenWithOption,
|
||||||
|
available_openers,
|
||||||
|
is_blocking_option,
|
||||||
|
launch_background_option,
|
||||||
|
launch_blocking_option,
|
||||||
|
)
|
||||||
|
|
||||||
ESCAPE_KEY = 27
|
ESCAPE_KEY = 27
|
||||||
|
BACKSPACE_KEYS = (curses.KEY_BACKSPACE, 127, 8)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -46,9 +54,13 @@ def format_entry(entry: VisibleEntry) -> str:
|
|||||||
return entry.name
|
return entry.name
|
||||||
|
|
||||||
indent = " " * entry.depth
|
indent = " " * entry.depth
|
||||||
indicator = " "
|
|
||||||
if entry.has_children:
|
if entry.is_file:
|
||||||
|
indicator = "•"
|
||||||
|
elif entry.has_children:
|
||||||
indicator = "▼" if entry.is_expanded else "▶"
|
indicator = "▼" if entry.is_expanded else "▶"
|
||||||
|
else:
|
||||||
|
indicator = " "
|
||||||
|
|
||||||
return f"{indent}{indicator} {entry.name}"
|
return f"{indent}{indicator} {entry.name}"
|
||||||
|
|
||||||
@@ -104,6 +116,11 @@ class TerminalUI:
|
|||||||
self._history_selected_index = 0
|
self._history_selected_index = 0
|
||||||
self._tree_scroll_offset = 0
|
self._tree_scroll_offset = 0
|
||||||
self._history_scroll_offset = 0
|
self._history_scroll_offset = 0
|
||||||
|
self._filter_mode = False
|
||||||
|
self._filter_query = ""
|
||||||
|
self._filter_selected_index = 0
|
||||||
|
self._filter_scroll_offset = 0
|
||||||
|
self._status_message: str | None = None
|
||||||
|
|
||||||
def run(self, navigator: Navigator) -> Path:
|
def run(self, navigator: Navigator) -> Path:
|
||||||
"""Start the interactive session and return the final directory path."""
|
"""Start the interactive session and return the final directory path."""
|
||||||
@@ -121,6 +138,16 @@ class TerminalUI:
|
|||||||
self._render(stdscr, navigator)
|
self._render(stdscr, navigator)
|
||||||
key = stdscr.getch()
|
key = stdscr.getch()
|
||||||
|
|
||||||
|
if self._history_mode:
|
||||||
|
self._handle_history_key(key, navigator, stdscr=stdscr)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if self._filter_mode:
|
||||||
|
filter_result = self._handle_filter_key(key, navigator, stdscr)
|
||||||
|
if filter_result is not None:
|
||||||
|
return filter_result
|
||||||
|
continue
|
||||||
|
|
||||||
if key == ESCAPE_KEY:
|
if key == ESCAPE_KEY:
|
||||||
return original_path
|
return original_path
|
||||||
|
|
||||||
@@ -128,11 +155,7 @@ class TerminalUI:
|
|||||||
self._toggle_history_mode(navigator)
|
self._toggle_history_mode(navigator)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if self._history_mode:
|
tree_result = self._handle_tree_key(key, navigator, stdscr=stdscr)
|
||||||
self._handle_history_key(key, navigator)
|
|
||||||
continue
|
|
||||||
|
|
||||||
tree_result = self._handle_tree_key(key, navigator)
|
|
||||||
if tree_result is not None:
|
if tree_result is not None:
|
||||||
return tree_result
|
return tree_result
|
||||||
|
|
||||||
@@ -145,30 +168,93 @@ class TerminalUI:
|
|||||||
stdscr.refresh()
|
stdscr.refresh()
|
||||||
return
|
return
|
||||||
|
|
||||||
path_text = str(navigator.current_path)
|
hidden_status = "on" if navigator.show_hidden else "off"
|
||||||
stdscr.addnstr(0, 0, path_text, width - 1)
|
file_status = "on" if navigator.show_files else "off"
|
||||||
|
path_text = (
|
||||||
lines = build_render_lines(navigator)
|
f"{navigator.current_path} [hidden: {hidden_status}] [files: {file_status}]"
|
||||||
visible_count = max(0, height - 2)
|
|
||||||
self._tree_scroll_offset = clamp_scroll_offset(
|
|
||||||
navigator.selected_index,
|
|
||||||
self._tree_scroll_offset,
|
|
||||||
visible_count,
|
|
||||||
len(lines),
|
|
||||||
)
|
)
|
||||||
|
stdscr.addnstr(0, 0, path_text, max(0, width - 1))
|
||||||
|
|
||||||
for row, line in enumerate(
|
start_row = 2
|
||||||
lines[self._tree_scroll_offset : self._tree_scroll_offset + visible_count],
|
if self._filter_mode:
|
||||||
start=2,
|
stdscr.addnstr(
|
||||||
):
|
1,
|
||||||
if row >= height:
|
0,
|
||||||
break
|
f"Filter: {self._filter_query}",
|
||||||
|
max(0, width - 1),
|
||||||
|
curses.A_BOLD,
|
||||||
|
)
|
||||||
|
start_row = 3
|
||||||
|
|
||||||
attributes = curses.A_REVERSE if line.is_selected else curses.A_NORMAL
|
lines, selected_index = self._build_tree_lines_for_render(navigator)
|
||||||
stdscr.addnstr(row, 0, line.text, width - 1, attributes)
|
footer_rows = 1 if self._status_message else 0
|
||||||
|
visible_count = max(0, height - start_row - footer_rows)
|
||||||
|
|
||||||
|
if self._filter_mode:
|
||||||
|
self._filter_scroll_offset = clamp_scroll_offset(
|
||||||
|
selected_index,
|
||||||
|
self._filter_scroll_offset,
|
||||||
|
visible_count,
|
||||||
|
len(lines),
|
||||||
|
)
|
||||||
|
scroll_offset = self._filter_scroll_offset
|
||||||
|
else:
|
||||||
|
self._tree_scroll_offset = clamp_scroll_offset(
|
||||||
|
selected_index,
|
||||||
|
self._tree_scroll_offset,
|
||||||
|
visible_count,
|
||||||
|
len(lines),
|
||||||
|
)
|
||||||
|
scroll_offset = self._tree_scroll_offset
|
||||||
|
|
||||||
|
if self._filter_mode and self._filter_query and not lines:
|
||||||
|
stdscr.addnstr(start_row, 0, "No matches", max(0, width - 1), curses.A_DIM)
|
||||||
|
else:
|
||||||
|
for row, line in enumerate(
|
||||||
|
lines[scroll_offset : scroll_offset + visible_count],
|
||||||
|
start=start_row,
|
||||||
|
):
|
||||||
|
if row >= height:
|
||||||
|
break
|
||||||
|
|
||||||
|
attributes = curses.A_REVERSE if line.is_selected else curses.A_NORMAL
|
||||||
|
stdscr.addnstr(row, 0, line.text, max(0, width - 1), attributes)
|
||||||
|
|
||||||
|
if self._status_message and height > 0:
|
||||||
|
stdscr.addnstr(
|
||||||
|
height - 1,
|
||||||
|
0,
|
||||||
|
self._status_message,
|
||||||
|
max(0, width - 1),
|
||||||
|
curses.A_DIM,
|
||||||
|
)
|
||||||
|
|
||||||
stdscr.refresh()
|
stdscr.refresh()
|
||||||
|
|
||||||
|
def _build_tree_lines_for_render(
|
||||||
|
self, navigator: Navigator
|
||||||
|
) -> tuple[list[RenderLine], int]:
|
||||||
|
if not self._filter_mode:
|
||||||
|
return build_render_lines(navigator), navigator.selected_index
|
||||||
|
|
||||||
|
entries = navigator.visible_entries
|
||||||
|
matches = self._matching_indices(entries)
|
||||||
|
if not matches:
|
||||||
|
self._filter_selected_index = 0
|
||||||
|
return [], 0
|
||||||
|
|
||||||
|
self._filter_selected_index = max(
|
||||||
|
0, min(self._filter_selected_index, len(matches) - 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
RenderLine(
|
||||||
|
text=format_entry(entries[entry_index]),
|
||||||
|
is_selected=match_index == self._filter_selected_index,
|
||||||
|
)
|
||||||
|
for match_index, entry_index in enumerate(matches)
|
||||||
|
], self._filter_selected_index
|
||||||
|
|
||||||
def _render_history_mode(
|
def _render_history_mode(
|
||||||
self, stdscr: curses.window, navigator: Navigator, width: int, height: int
|
self, stdscr: curses.window, navigator: Navigator, width: int, height: int
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -176,7 +262,8 @@ class TerminalUI:
|
|||||||
navigator.history.entries(), self._history_selected_index
|
navigator.history.entries(), self._history_selected_index
|
||||||
)
|
)
|
||||||
start_row = 1
|
start_row = 1
|
||||||
visible_count = max(0, height - start_row - 1)
|
footer_rows = 1 if self._status_message else 0
|
||||||
|
visible_count = max(0, height - start_row - 1 - footer_rows)
|
||||||
self._history_scroll_offset = clamp_scroll_offset(
|
self._history_scroll_offset = clamp_scroll_offset(
|
||||||
self._history_selected_index,
|
self._history_selected_index,
|
||||||
self._history_scroll_offset,
|
self._history_scroll_offset,
|
||||||
@@ -184,7 +271,7 @@ class TerminalUI:
|
|||||||
len(history_lines),
|
len(history_lines),
|
||||||
)
|
)
|
||||||
|
|
||||||
stdscr.addnstr(start_row, 0, "History:", width - 1, curses.A_BOLD)
|
stdscr.addnstr(start_row, 0, "History:", max(0, width - 1), curses.A_BOLD)
|
||||||
|
|
||||||
for index, line in enumerate(
|
for index, line in enumerate(
|
||||||
history_lines[
|
history_lines[
|
||||||
@@ -198,17 +285,33 @@ class TerminalUI:
|
|||||||
break
|
break
|
||||||
|
|
||||||
attributes = curses.A_REVERSE if line.is_selected else curses.A_NORMAL
|
attributes = curses.A_REVERSE if line.is_selected else curses.A_NORMAL
|
||||||
stdscr.addnstr(row, 0, line.text, width - 1, attributes)
|
stdscr.addnstr(row, 0, line.text, max(0, width - 1), attributes)
|
||||||
|
|
||||||
|
if self._status_message and height > 0:
|
||||||
|
stdscr.addnstr(
|
||||||
|
height - 1,
|
||||||
|
0,
|
||||||
|
self._status_message,
|
||||||
|
max(0, width - 1),
|
||||||
|
curses.A_DIM,
|
||||||
|
)
|
||||||
|
|
||||||
def _toggle_history_mode(self, navigator: Navigator) -> None:
|
def _toggle_history_mode(self, navigator: Navigator) -> None:
|
||||||
self._history_mode = not self._history_mode
|
self._history_mode = not self._history_mode
|
||||||
|
self._status_message = None
|
||||||
if self._history_mode:
|
if self._history_mode:
|
||||||
self._history_selected_index = self._get_current_history_index(navigator)
|
self._history_selected_index = self._get_current_history_index(navigator)
|
||||||
self._history_scroll_offset = 0
|
self._history_scroll_offset = 0
|
||||||
|
|
||||||
def _handle_history_key(self, key: int, navigator: Navigator) -> None:
|
def _handle_history_key(
|
||||||
|
self, key: int, navigator: Navigator, stdscr: curses.window | None = None
|
||||||
|
) -> None:
|
||||||
history_entries = navigator.history.entries()
|
history_entries = navigator.history.entries()
|
||||||
|
|
||||||
|
if key in (ESCAPE_KEY, ord("h")):
|
||||||
|
self._history_mode = False
|
||||||
|
return
|
||||||
|
|
||||||
if key == curses.KEY_UP:
|
if key == curses.KEY_UP:
|
||||||
self._history_selected_index = move_selection(
|
self._history_selected_index = move_selection(
|
||||||
self._history_selected_index,
|
self._history_selected_index,
|
||||||
@@ -225,12 +328,134 @@ class TerminalUI:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if key == curses.KEY_PPAGE:
|
||||||
|
step = -self._page_step(stdscr, start_row=1)
|
||||||
|
self._history_selected_index = move_selection(
|
||||||
|
self._history_selected_index,
|
||||||
|
step=step,
|
||||||
|
entry_count=len(history_entries),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if key == curses.KEY_NPAGE:
|
||||||
|
step = self._page_step(stdscr, start_row=1)
|
||||||
|
self._history_selected_index = move_selection(
|
||||||
|
self._history_selected_index,
|
||||||
|
step=step,
|
||||||
|
entry_count=len(history_entries),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if key in (curses.KEY_HOME, ord("g")):
|
||||||
|
self._history_selected_index = 0
|
||||||
|
return
|
||||||
|
|
||||||
|
if key in (curses.KEY_END, ord("G")) and history_entries:
|
||||||
|
self._history_selected_index = len(history_entries) - 1
|
||||||
|
return
|
||||||
|
|
||||||
if key in (curses.KEY_ENTER, 10, 13) and history_entries:
|
if key in (curses.KEY_ENTER, 10, 13) and history_entries:
|
||||||
navigator.select_history_entry(self._history_selected_index)
|
navigator.select_history_entry(self._history_selected_index)
|
||||||
self._history_mode = False
|
self._history_mode = False
|
||||||
self._tree_scroll_offset = 0
|
self._tree_scroll_offset = 0
|
||||||
|
|
||||||
def _handle_tree_key(self, key: int, navigator: Navigator) -> Path | None:
|
def _handle_filter_key(
|
||||||
|
self, key: int, navigator: Navigator, stdscr: curses.window | None = None
|
||||||
|
) -> Path | None:
|
||||||
|
entries = navigator.visible_entries
|
||||||
|
matches = self._matching_indices(entries)
|
||||||
|
|
||||||
|
if key == ESCAPE_KEY:
|
||||||
|
self._filter_mode = False
|
||||||
|
self._filter_query = ""
|
||||||
|
self._filter_selected_index = 0
|
||||||
|
self._filter_scroll_offset = 0
|
||||||
|
return None
|
||||||
|
|
||||||
|
if key in (curses.KEY_ENTER, 10, 13):
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
|
|
||||||
|
selected_entry_index = matches[self._filter_selected_index]
|
||||||
|
navigator.set_selected_index(selected_entry_index)
|
||||||
|
selected = entries[selected_entry_index]
|
||||||
|
|
||||||
|
self._filter_mode = False
|
||||||
|
self._filter_query = ""
|
||||||
|
self._filter_selected_index = 0
|
||||||
|
self._filter_scroll_offset = 0
|
||||||
|
|
||||||
|
if selected.is_file:
|
||||||
|
self._open_with_selected(navigator, stdscr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
navigator.enter_selected_directory()
|
||||||
|
self._tree_scroll_offset = 0
|
||||||
|
return None
|
||||||
|
|
||||||
|
if key == curses.KEY_UP:
|
||||||
|
self._filter_selected_index = move_selection(
|
||||||
|
self._filter_selected_index,
|
||||||
|
step=-1,
|
||||||
|
entry_count=len(matches),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if key == curses.KEY_DOWN:
|
||||||
|
self._filter_selected_index = move_selection(
|
||||||
|
self._filter_selected_index,
|
||||||
|
step=1,
|
||||||
|
entry_count=len(matches),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if key == curses.KEY_PPAGE:
|
||||||
|
step = -self._page_step(stdscr, start_row=3)
|
||||||
|
self._filter_selected_index = move_selection(
|
||||||
|
self._filter_selected_index,
|
||||||
|
step=step,
|
||||||
|
entry_count=len(matches),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if key == curses.KEY_NPAGE:
|
||||||
|
step = self._page_step(stdscr, start_row=3)
|
||||||
|
self._filter_selected_index = move_selection(
|
||||||
|
self._filter_selected_index,
|
||||||
|
step=step,
|
||||||
|
entry_count=len(matches),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if key == curses.KEY_HOME:
|
||||||
|
self._filter_selected_index = 0
|
||||||
|
return None
|
||||||
|
|
||||||
|
if key == curses.KEY_END and matches:
|
||||||
|
self._filter_selected_index = len(matches) - 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
if key in BACKSPACE_KEYS:
|
||||||
|
self._filter_query = self._filter_query[:-1]
|
||||||
|
self._filter_selected_index = 0
|
||||||
|
self._filter_scroll_offset = 0
|
||||||
|
return None
|
||||||
|
|
||||||
|
if 32 <= key <= 126:
|
||||||
|
self._filter_query += chr(key)
|
||||||
|
self._filter_selected_index = 0
|
||||||
|
self._filter_scroll_offset = 0
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _handle_tree_key(
|
||||||
|
self,
|
||||||
|
key: int,
|
||||||
|
navigator: Navigator,
|
||||||
|
stdscr: curses.window | None = None,
|
||||||
|
) -> Path | None:
|
||||||
|
self._status_message = None
|
||||||
|
|
||||||
if key == curses.KEY_UP:
|
if key == curses.KEY_UP:
|
||||||
navigator.set_selected_index(
|
navigator.set_selected_index(
|
||||||
move_selection(
|
move_selection(
|
||||||
@@ -251,8 +476,40 @@ class TerminalUI:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if key == curses.KEY_PPAGE:
|
||||||
|
navigator.set_selected_index(
|
||||||
|
move_selection(
|
||||||
|
navigator.selected_index,
|
||||||
|
step=-self._page_step(stdscr, start_row=2),
|
||||||
|
entry_count=len(navigator.visible_entries),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if key == curses.KEY_NPAGE:
|
||||||
|
navigator.set_selected_index(
|
||||||
|
move_selection(
|
||||||
|
navigator.selected_index,
|
||||||
|
step=self._page_step(stdscr, start_row=2),
|
||||||
|
entry_count=len(navigator.visible_entries),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if key in (curses.KEY_HOME, ord("g")):
|
||||||
|
navigator.set_selected_index(0)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if key in (curses.KEY_END, ord("G")):
|
||||||
|
navigator.set_selected_index(len(navigator.visible_entries) - 1)
|
||||||
|
return None
|
||||||
|
|
||||||
if key == curses.KEY_RIGHT:
|
if key == curses.KEY_RIGHT:
|
||||||
was_expanded = navigator.selected_entry.is_expanded
|
selected = navigator.selected_entry
|
||||||
|
if selected.is_file:
|
||||||
|
return None
|
||||||
|
|
||||||
|
was_expanded = selected.is_expanded
|
||||||
|
|
||||||
if not navigator.expand_selected_directory():
|
if not navigator.expand_selected_directory():
|
||||||
navigator.enter_selected_directory()
|
navigator.enter_selected_directory()
|
||||||
@@ -282,11 +539,172 @@ class TerminalUI:
|
|||||||
self._tree_scroll_offset = 0
|
self._tree_scroll_offset = 0
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if key == ord("."):
|
||||||
|
navigator.toggle_hidden()
|
||||||
|
self._tree_scroll_offset = 0
|
||||||
|
return None
|
||||||
|
|
||||||
|
if key == ord("a"):
|
||||||
|
navigator.toggle_files()
|
||||||
|
self._tree_scroll_offset = 0
|
||||||
|
return None
|
||||||
|
|
||||||
|
if key == ord("/"):
|
||||||
|
self._filter_mode = True
|
||||||
|
self._filter_query = ""
|
||||||
|
self._filter_selected_index = 0
|
||||||
|
self._filter_scroll_offset = 0
|
||||||
|
return None
|
||||||
|
|
||||||
|
if key == ord("o"):
|
||||||
|
self._open_with_selected(navigator, stdscr)
|
||||||
|
return None
|
||||||
|
|
||||||
if key in (curses.KEY_ENTER, 10, 13):
|
if key in (curses.KEY_ENTER, 10, 13):
|
||||||
return navigator.selected_entry.path
|
return navigator.selected_entry.path
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _matching_indices(self, entries: tuple[VisibleEntry, ...]) -> list[int]:
|
||||||
|
if not self._filter_query:
|
||||||
|
return list(range(len(entries)))
|
||||||
|
|
||||||
|
query = self._filter_query.casefold()
|
||||||
|
return [
|
||||||
|
index
|
||||||
|
for index, entry in enumerate(entries)
|
||||||
|
if query in entry.name.casefold()
|
||||||
|
]
|
||||||
|
|
||||||
|
def _page_step(self, stdscr: curses.window | None, start_row: int) -> int:
|
||||||
|
if stdscr is None:
|
||||||
|
return 10
|
||||||
|
|
||||||
|
height, _width = stdscr.getmaxyx()
|
||||||
|
footer_rows = 1 if self._status_message else 0
|
||||||
|
return max(1, height - start_row - footer_rows)
|
||||||
|
|
||||||
|
def _open_with_selected(
|
||||||
|
self, navigator: Navigator, stdscr: curses.window | None
|
||||||
|
) -> None:
|
||||||
|
selected = navigator.selected_entry
|
||||||
|
options = available_openers(selected.path)
|
||||||
|
if not options:
|
||||||
|
self._status_message = "No applications available for this entry"
|
||||||
|
return
|
||||||
|
|
||||||
|
option_index = 0
|
||||||
|
if stdscr is not None:
|
||||||
|
choice = self._prompt_open_with_choice(stdscr, options, selected.name)
|
||||||
|
if choice is None:
|
||||||
|
self._status_message = "Open cancelled"
|
||||||
|
return
|
||||||
|
|
||||||
|
option_index = choice
|
||||||
|
|
||||||
|
option = options[option_index]
|
||||||
|
if is_blocking_option(option):
|
||||||
|
success, message = self._launch_blocking_with_terminal_restore(
|
||||||
|
stdscr, selected.path, option
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
success, message = launch_background_option(selected.path, option)
|
||||||
|
|
||||||
|
self._status_message = message
|
||||||
|
if not success:
|
||||||
|
self._status_message = message
|
||||||
|
|
||||||
|
def _prompt_open_with_choice(
|
||||||
|
self,
|
||||||
|
stdscr: curses.window,
|
||||||
|
options: list[OpenWithOption],
|
||||||
|
entry_name: str,
|
||||||
|
) -> int | None:
|
||||||
|
selected_index = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
height, width = stdscr.getmaxyx()
|
||||||
|
title_row = max(0, height - len(options) - 2)
|
||||||
|
title = f"Open '{entry_name}' with (↑/↓, Enter, Esc):"
|
||||||
|
|
||||||
|
stdscr.addnstr(
|
||||||
|
title_row,
|
||||||
|
0,
|
||||||
|
" " * max(0, width - 1),
|
||||||
|
max(0, width - 1),
|
||||||
|
)
|
||||||
|
stdscr.addnstr(
|
||||||
|
title_row,
|
||||||
|
0,
|
||||||
|
title,
|
||||||
|
max(0, width - 1),
|
||||||
|
curses.A_BOLD,
|
||||||
|
)
|
||||||
|
|
||||||
|
for index, option in enumerate(options):
|
||||||
|
row = title_row + index + 1
|
||||||
|
if row >= height:
|
||||||
|
break
|
||||||
|
|
||||||
|
attributes = (
|
||||||
|
curses.A_REVERSE if index == selected_index else curses.A_NORMAL
|
||||||
|
)
|
||||||
|
label = f" {option.label}"
|
||||||
|
stdscr.addnstr(
|
||||||
|
row,
|
||||||
|
0,
|
||||||
|
" " * max(0, width - 1),
|
||||||
|
max(0, width - 1),
|
||||||
|
)
|
||||||
|
stdscr.addnstr(row, 0, label, max(0, width - 1), attributes)
|
||||||
|
|
||||||
|
stdscr.refresh()
|
||||||
|
key = stdscr.getch()
|
||||||
|
|
||||||
|
if key == ESCAPE_KEY:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if key == curses.KEY_UP:
|
||||||
|
selected_index = move_selection(
|
||||||
|
selected_index,
|
||||||
|
step=-1,
|
||||||
|
entry_count=len(options),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if key == curses.KEY_DOWN:
|
||||||
|
selected_index = move_selection(
|
||||||
|
selected_index,
|
||||||
|
step=1,
|
||||||
|
entry_count=len(options),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if key in (curses.KEY_ENTER, 10, 13):
|
||||||
|
return selected_index
|
||||||
|
|
||||||
|
def _launch_blocking_with_terminal_restore(
|
||||||
|
self,
|
||||||
|
stdscr: curses.window | None,
|
||||||
|
path: Path,
|
||||||
|
option: OpenWithOption,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
if stdscr is None:
|
||||||
|
return launch_blocking_option(path, option)
|
||||||
|
|
||||||
|
curses.def_prog_mode()
|
||||||
|
curses.endwin()
|
||||||
|
|
||||||
|
try:
|
||||||
|
success, message = launch_blocking_option(path, option)
|
||||||
|
finally:
|
||||||
|
curses.reset_prog_mode()
|
||||||
|
stdscr.keypad(True)
|
||||||
|
stdscr.clear()
|
||||||
|
stdscr.refresh()
|
||||||
|
|
||||||
|
return success, message
|
||||||
|
|
||||||
def _get_current_history_index(self, navigator: Navigator) -> int:
|
def _get_current_history_index(self, navigator: Navigator) -> int:
|
||||||
history_entries = navigator.history.entries()
|
history_entries = navigator.history.entries()
|
||||||
|
|
||||||
|
|||||||
84
src/cd_browser_post_install.py
Normal file
84
src/cd_browser_post_install.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Post-install script for cd-browser to guide users on shell integration."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_for_shell(shell: str) -> str:
|
||||||
|
if shell == "zsh":
|
||||||
|
return "~/.zshrc"
|
||||||
|
|
||||||
|
return "~/.bashrc"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_cd_function_present(profile_path: str) -> bool:
|
||||||
|
if not os.path.exists(profile_path):
|
||||||
|
return False
|
||||||
|
|
||||||
|
with open(profile_path, encoding="utf-8") as profile_file:
|
||||||
|
return "cd_() {" in profile_file.read()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Print shell integration instructions after installation."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("🎉 cd-browser installed successfully!")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"To enable the 'cd_' command, you need to add a function to your shell profile."
|
||||||
|
)
|
||||||
|
print()
|
||||||
|
|
||||||
|
shell = os.environ.get("SHELL", "").split("/")[-1]
|
||||||
|
profile = _profile_for_shell(shell)
|
||||||
|
|
||||||
|
cd_function = """cd_() {
|
||||||
|
local target
|
||||||
|
target="$(cd_browser)" || return
|
||||||
|
if [ -n "$target" ] && [ -d "$target" ]; then
|
||||||
|
cd "$target"
|
||||||
|
fi
|
||||||
|
}"""
|
||||||
|
|
||||||
|
print(f"Copy this to the end of {profile}:")
|
||||||
|
print()
|
||||||
|
print(cd_function)
|
||||||
|
print()
|
||||||
|
|
||||||
|
profile_path = os.path.expanduser(profile)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = (
|
||||||
|
input("Do you want me to add it automatically? (y/n): ").strip().lower()
|
||||||
|
)
|
||||||
|
if response == "y":
|
||||||
|
if _is_cd_function_present(profile_path):
|
||||||
|
print(f"\nℹ️ cd_() already exists in {profile}, no changes made.")
|
||||||
|
else:
|
||||||
|
with open(profile_path, "a", encoding="utf-8") as f:
|
||||||
|
f.write("\n" + cd_function + "\n")
|
||||||
|
print(f"\n✅ Added to {profile}")
|
||||||
|
print(f"Run 'source {profile}' or restart your terminal to activate cd_.")
|
||||||
|
else:
|
||||||
|
print(f"\nAdd the function to {profile} manually.")
|
||||||
|
print(f"Then run 'source {profile}' or restart terminal.")
|
||||||
|
except EOFError:
|
||||||
|
print(
|
||||||
|
f"\nNo interactive input detected. Add the function to {profile} manually."
|
||||||
|
)
|
||||||
|
print(f"Then run 'source {profile}' or restart terminal.")
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\nSetup cancelled. You can add the function manually later.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(
|
||||||
|
f"For uninstall: after 'pip uninstall cd-browser', remove cd_() from {profile}."
|
||||||
|
)
|
||||||
|
print("=" * 60 + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -4,9 +4,10 @@ import pytest
|
|||||||
|
|
||||||
from app.cli import run_cli
|
from app.cli import run_cli
|
||||||
from app.navigator import Navigator
|
from app.navigator import Navigator
|
||||||
|
from app.ui import TerminalUI
|
||||||
|
|
||||||
|
|
||||||
class StubUI:
|
class StubUI(TerminalUI):
|
||||||
def __init__(self, result: Path) -> None:
|
def __init__(self, result: Path) -> None:
|
||||||
self.result = result
|
self.result = result
|
||||||
self.navigator: Navigator | None = None
|
self.navigator: Navigator | None = None
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.filesystem import DirectoryEntry, has_subdirectories, list_directories
|
from app.filesystem import (
|
||||||
|
DirectoryEntry,
|
||||||
|
FileSystemEntry,
|
||||||
|
has_subdirectories,
|
||||||
|
list_directories,
|
||||||
|
list_entries,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_has_subdirectories_detects_nested_directory(tmp_path: Path) -> None:
|
def test_has_subdirectories_detects_nested_directory(tmp_path: Path) -> None:
|
||||||
@@ -44,3 +50,55 @@ def test_list_directories_raises_for_missing_directory(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
with pytest.raises(FileNotFoundError):
|
with pytest.raises(FileNotFoundError):
|
||||||
list_directories(missing_path)
|
list_directories(missing_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_entries_returns_files_when_include_files_enabled(tmp_path: Path) -> None:
|
||||||
|
folder = tmp_path / "folder"
|
||||||
|
file_path = tmp_path / "notes.txt"
|
||||||
|
folder.mkdir()
|
||||||
|
file_path.write_text("content", encoding="utf-8")
|
||||||
|
|
||||||
|
assert list_entries(tmp_path, include_files=True) == [
|
||||||
|
FileSystemEntry(
|
||||||
|
name="folder",
|
||||||
|
path=folder,
|
||||||
|
is_directory=True,
|
||||||
|
has_children=False,
|
||||||
|
),
|
||||||
|
FileSystemEntry(
|
||||||
|
name="notes.txt",
|
||||||
|
path=file_path,
|
||||||
|
is_directory=False,
|
||||||
|
has_children=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_directories_skips_permission_errors_for_children(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
root.mkdir()
|
||||||
|
allowed = root / "allowed"
|
||||||
|
blocked = root / "blocked"
|
||||||
|
allowed.mkdir()
|
||||||
|
blocked.mkdir()
|
||||||
|
|
||||||
|
blocked.chmod(0)
|
||||||
|
try:
|
||||||
|
entries = list_directories(root)
|
||||||
|
finally:
|
||||||
|
blocked.chmod(0o700)
|
||||||
|
|
||||||
|
assert any(entry.name == "allowed" for entry in entries)
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_subdirectories_returns_false_on_permission_error(tmp_path: Path) -> None:
|
||||||
|
blocked = tmp_path / "blocked"
|
||||||
|
blocked.mkdir()
|
||||||
|
|
||||||
|
blocked.chmod(0)
|
||||||
|
try:
|
||||||
|
result = has_subdirectories(blocked)
|
||||||
|
finally:
|
||||||
|
blocked.chmod(0o700)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|||||||
@@ -25,6 +25,49 @@ def test_navigator_initial_state_includes_parent_entry(tmp_path: Path) -> None:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_hidden_directories(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
root.mkdir()
|
||||||
|
visible = root / "visible"
|
||||||
|
visible.mkdir()
|
||||||
|
hidden = root / ".hidden"
|
||||||
|
hidden.mkdir()
|
||||||
|
|
||||||
|
navigator = Navigator(root)
|
||||||
|
|
||||||
|
assert [entry.name for entry in navigator.visible_entries] == ["..", "visible"]
|
||||||
|
|
||||||
|
navigator.toggle_hidden()
|
||||||
|
assert navigator.show_hidden is True
|
||||||
|
assert [entry.name for entry in navigator.visible_entries] == [
|
||||||
|
"..",
|
||||||
|
".hidden",
|
||||||
|
"visible",
|
||||||
|
]
|
||||||
|
|
||||||
|
navigator.toggle_hidden()
|
||||||
|
assert navigator.show_hidden is False
|
||||||
|
assert [entry.name for entry in navigator.visible_entries] == ["..", "visible"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_files_includes_files_in_visible_entries(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "folder").mkdir()
|
||||||
|
(root / "notes.txt").write_text("hello", encoding="utf-8")
|
||||||
|
|
||||||
|
navigator = Navigator(root)
|
||||||
|
assert [entry.name for entry in navigator.visible_entries] == ["..", "folder"]
|
||||||
|
|
||||||
|
navigator.toggle_files()
|
||||||
|
assert navigator.show_files is True
|
||||||
|
assert [entry.name for entry in navigator.visible_entries] == [
|
||||||
|
"..",
|
||||||
|
"folder",
|
||||||
|
"notes.txt",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_expand_selected_directory_reveals_nested_entries(tmp_path: Path) -> None:
|
def test_expand_selected_directory_reveals_nested_entries(tmp_path: Path) -> None:
|
||||||
current = tmp_path / "workspace"
|
current = tmp_path / "workspace"
|
||||||
current.mkdir()
|
current.mkdir()
|
||||||
|
|||||||
147
tests/test_opener.py
Normal file
147
tests/test_opener.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from subprocess import CompletedProcess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.opener import (
|
||||||
|
OpenWithOption,
|
||||||
|
available_openers,
|
||||||
|
is_blocking_option,
|
||||||
|
launch_background_option,
|
||||||
|
launch_blocking_option,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_available_openers_headless_file_excludes_gui(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
file_path = tmp_path / "notes.txt"
|
||||||
|
file_path.write_text("hello", encoding="utf-8")
|
||||||
|
|
||||||
|
monkeypatch.setenv("SSH_CONNECTION", "1")
|
||||||
|
|
||||||
|
available = {
|
||||||
|
"antigravity": "/usr/local/bin/antigravity",
|
||||||
|
"nvim": "/usr/local/bin/nvim",
|
||||||
|
"nano": "/usr/bin/nano",
|
||||||
|
"less": "/usr/bin/less",
|
||||||
|
"bat": "/usr/local/bin/bat",
|
||||||
|
"code": "/usr/local/bin/code",
|
||||||
|
"open": "/usr/bin/open",
|
||||||
|
}
|
||||||
|
monkeypatch.setattr("app.opener.shutil.which", lambda name: available.get(name))
|
||||||
|
|
||||||
|
labels = [option.label for option in available_openers(file_path)]
|
||||||
|
|
||||||
|
assert "VS Code" not in labels
|
||||||
|
assert "Open (system)" not in labels
|
||||||
|
assert labels == ["Antigravity", "Neovim", "Nano", "Less", "Bat"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_available_openers_gui_directory_filters_file_only_tools(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
folder = tmp_path / "project"
|
||||||
|
folder.mkdir()
|
||||||
|
|
||||||
|
monkeypatch.delenv("SSH_CONNECTION", raising=False)
|
||||||
|
monkeypatch.setattr("app.opener.sys.platform", "darwin")
|
||||||
|
|
||||||
|
available = {
|
||||||
|
"antigravity": "/usr/local/bin/antigravity",
|
||||||
|
"nvim": "/usr/local/bin/nvim",
|
||||||
|
"nano": "/usr/bin/nano",
|
||||||
|
"less": "/usr/bin/less",
|
||||||
|
"bat": "/usr/local/bin/bat",
|
||||||
|
"code": "/usr/local/bin/code",
|
||||||
|
"open": "/usr/bin/open",
|
||||||
|
}
|
||||||
|
monkeypatch.setattr("app.opener.shutil.which", lambda name: available.get(name))
|
||||||
|
|
||||||
|
labels = [option.label for option in available_openers(folder)]
|
||||||
|
|
||||||
|
assert labels == ["VS Code", "Antigravity", "Neovim"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_launch_background_option_returns_success(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
option = OpenWithOption(
|
||||||
|
label="VS Code",
|
||||||
|
command_template="code {path}",
|
||||||
|
executable="code",
|
||||||
|
launch_mode="background",
|
||||||
|
requires_gui=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.opener.subprocess.Popen", lambda *args, **kwargs: None)
|
||||||
|
|
||||||
|
success, message = launch_background_option(tmp_path, option)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
assert "VS Code" in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_launch_blocking_option_reports_non_zero_exit(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
option = OpenWithOption(
|
||||||
|
label="Neovim",
|
||||||
|
command_template="nvim {path}",
|
||||||
|
executable="nvim",
|
||||||
|
launch_mode="blocking",
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.opener.subprocess.run",
|
||||||
|
lambda *args, **kwargs: CompletedProcess(args=[], returncode=2),
|
||||||
|
)
|
||||||
|
|
||||||
|
success, message = launch_blocking_option(tmp_path, option)
|
||||||
|
|
||||||
|
assert success is False
|
||||||
|
assert "code 2" in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_blocking_option_uses_launch_mode() -> None:
|
||||||
|
blocking = OpenWithOption(
|
||||||
|
label="Neovim",
|
||||||
|
command_template="nvim {path}",
|
||||||
|
executable="nvim",
|
||||||
|
launch_mode="blocking",
|
||||||
|
)
|
||||||
|
background = OpenWithOption(
|
||||||
|
label="VS Code",
|
||||||
|
command_template="code {path}",
|
||||||
|
executable="code",
|
||||||
|
launch_mode="background",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert is_blocking_option(blocking) is True
|
||||||
|
assert is_blocking_option(background) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_launch_blocking_option_handles_paths_with_spaces(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
target_path = tmp_path / "COMPETENCIAS A.LEDO"
|
||||||
|
option = OpenWithOption(
|
||||||
|
label="Neovim",
|
||||||
|
command_template="nvim {path}",
|
||||||
|
executable="nvim",
|
||||||
|
launch_mode="blocking",
|
||||||
|
)
|
||||||
|
captured: dict[str, list[str]] = {}
|
||||||
|
|
||||||
|
def fake_run(command: list[str], check: bool) -> CompletedProcess[object]:
|
||||||
|
captured["command"] = command
|
||||||
|
return CompletedProcess(args=[], returncode=0)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.opener.subprocess.run", fake_run)
|
||||||
|
|
||||||
|
success, _message = launch_blocking_option(target_path, option)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
assert captured["command"] == ["nvim", str(target_path)]
|
||||||
47
tests/test_post_install.py
Normal file
47
tests/test_post_install.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cd_browser_post_install import main
|
||||||
|
|
||||||
|
CD_FUNCTION_SNIPPET = "cd_() {"
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_install_handles_eof_without_crashing(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("SHELL", "/bin/zsh")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"builtins.input", lambda _prompt: (_ for _ in ()).throw(EOFError)
|
||||||
|
)
|
||||||
|
|
||||||
|
main()
|
||||||
|
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "No interactive input detected" in output
|
||||||
|
assert "source ~/.zshrc" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_install_adds_cd_function_once(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("SHELL", "/bin/zsh")
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
|
||||||
|
responses = iter(["y", "y"])
|
||||||
|
monkeypatch.setattr("builtins.input", lambda _prompt: next(responses))
|
||||||
|
|
||||||
|
main()
|
||||||
|
main()
|
||||||
|
|
||||||
|
profile_path = tmp_path / ".zshrc"
|
||||||
|
content = profile_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert content.count(CD_FUNCTION_SNIPPET) == 1
|
||||||
|
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "already exists" in output
|
||||||
|
assert "source ~/.zshrc" in output
|
||||||
119
tests/test_ui.py
119
tests/test_ui.py
@@ -52,6 +52,19 @@ def test_format_entry_supports_parent_and_tree_indicators(tmp_path: Path) -> Non
|
|||||||
assert format_entry(expanded_child) == " build"
|
assert format_entry(expanded_child) == " build"
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_entry_marks_files_with_file_indicator(tmp_path: Path) -> None:
|
||||||
|
file_entry = VisibleEntry(
|
||||||
|
name="notes.txt",
|
||||||
|
path=tmp_path / "notes.txt",
|
||||||
|
depth=0,
|
||||||
|
has_children=False,
|
||||||
|
is_expanded=False,
|
||||||
|
is_file=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert format_entry(file_entry) == "• notes.txt"
|
||||||
|
|
||||||
|
|
||||||
def test_build_render_lines_marks_selected_entry(tmp_path: Path) -> None:
|
def test_build_render_lines_marks_selected_entry(tmp_path: Path) -> None:
|
||||||
workspace = tmp_path / "workspace"
|
workspace = tmp_path / "workspace"
|
||||||
workspace.mkdir()
|
workspace.mkdir()
|
||||||
@@ -221,6 +234,112 @@ def test_enter_returns_selected_path_without_navigating(tmp_path: Path) -> None:
|
|||||||
assert navigator.current_path == workspace.resolve()
|
assert navigator.current_path == workspace.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_files_key_updates_navigator_state(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "folder").mkdir()
|
||||||
|
(root / "notes.txt").write_text("content", encoding="utf-8")
|
||||||
|
|
||||||
|
navigator = Navigator(root)
|
||||||
|
ui = TerminalUI()
|
||||||
|
|
||||||
|
assert navigator.show_files is False
|
||||||
|
assert ui._handle_tree_key(ord("a"), navigator) is None
|
||||||
|
assert navigator.show_files is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_matching_is_case_insensitive(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "Alpha").mkdir()
|
||||||
|
(root / "beta").mkdir()
|
||||||
|
|
||||||
|
navigator = Navigator(root)
|
||||||
|
ui = TerminalUI()
|
||||||
|
ui._filter_mode = True
|
||||||
|
ui._filter_query = "alp"
|
||||||
|
|
||||||
|
matches = ui._matching_indices(navigator.visible_entries)
|
||||||
|
|
||||||
|
assert matches == [1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_mode_uses_g_and_g_upper_as_query_characters(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "alpha").mkdir()
|
||||||
|
(root / "gamma").mkdir()
|
||||||
|
|
||||||
|
navigator = Navigator(root)
|
||||||
|
ui = TerminalUI()
|
||||||
|
ui._filter_mode = True
|
||||||
|
|
||||||
|
assert ui._handle_filter_key(ord("g"), navigator) is None
|
||||||
|
assert ui._handle_filter_key(ord("G"), navigator) is None
|
||||||
|
assert ui._filter_query == "gG"
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_enter_on_directory_navigates_and_exits_filter(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
child = root / "child"
|
||||||
|
root.mkdir()
|
||||||
|
child.mkdir()
|
||||||
|
|
||||||
|
navigator = Navigator(root)
|
||||||
|
ui = TerminalUI()
|
||||||
|
ui._filter_mode = True
|
||||||
|
ui._filter_query = "child"
|
||||||
|
|
||||||
|
result = ui._handle_filter_key(10, navigator)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert ui._filter_mode is False
|
||||||
|
assert navigator.current_path == child.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_enter_on_file_opens_menu_and_exits_filter(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
root.mkdir()
|
||||||
|
file_path = root / "notes.txt"
|
||||||
|
file_path.write_text("hello", encoding="utf-8")
|
||||||
|
|
||||||
|
navigator = Navigator(root)
|
||||||
|
navigator.toggle_files()
|
||||||
|
ui = TerminalUI()
|
||||||
|
ui._filter_mode = True
|
||||||
|
ui._filter_query = "notes"
|
||||||
|
opened: dict[str, bool] = {"called": False}
|
||||||
|
|
||||||
|
def fake_open_with_selected(
|
||||||
|
_navigator: Navigator, _stdscr: curses.window | None
|
||||||
|
) -> None:
|
||||||
|
opened["called"] = True
|
||||||
|
|
||||||
|
monkeypatch.setattr(ui, "_open_with_selected", fake_open_with_selected)
|
||||||
|
|
||||||
|
result = ui._handle_filter_key(10, navigator)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert ui._filter_mode is False
|
||||||
|
assert opened["called"] is True
|
||||||
|
assert navigator.selected_entry.path == file_path.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_end_key_jumps_to_last_entry(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
root.mkdir()
|
||||||
|
for name in ("alpha", "beta", "gamma"):
|
||||||
|
(root / name).mkdir()
|
||||||
|
|
||||||
|
navigator = Navigator(root)
|
||||||
|
ui = TerminalUI()
|
||||||
|
|
||||||
|
assert ui._handle_tree_key(curses.KEY_END, navigator) is None
|
||||||
|
assert navigator.selected_index == len(navigator.visible_entries) - 1
|
||||||
|
|
||||||
|
|
||||||
def test_open_terminal_streams_raises_without_terminal(
|
def test_open_terminal_streams_raises_without_terminal(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user