Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9f08895be | |||
| beab56393a | |||
| 6f78f6d9ca | |||
| 8259b2e132 | |||
| 4052c6f71f | |||
| 0e94895349 | |||
| d9cf164b2c | |||
| a64bc768b5 | |||
| f7f09b895d | |||
| 10c3849fb3 | |||
| f28fef7758 | |||
| ab689cfbb4 | |||
| 9688ed0974 | |||
| d9d412973b | |||
| 9704d3ef5b | |||
| 0118ad9314 | |||
| e766210920 | |||
| 6849631cc1 | |||
| 7c87d6f1f0 | |||
| 60723c33bc | |||
| 6195f7b8a3 | |||
| 801544951f | |||
| 65508d263f | |||
| 2bdfbe25d5 | |||
| 12d2577a31 |
93
CHANGELOG.md
93
CHANGELOG.md
@@ -4,16 +4,87 @@ 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.3.0] - 2026-04-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Initial release of the Python AI Dev Template
|
- Two-line compact header with live session state: version, history count, filter state, hidden/files toggles, and development summary
|
||||||
- Universal Python project structure using `src/`
|
- Persistent history storage between sessions with configurable state path and bounded history size
|
||||||
- `pyproject.toml`-based project configuration
|
- History list rendering in most-recent-first order
|
||||||
- Development tooling with `black`, `ruff`, `mypy`, and `pytest`
|
- History filter mode (`/` inside history) with dedicated query and navigation behavior
|
||||||
- `pre-commit` integration
|
- Development info panel (`i`) with git context, detected stack, and suggested commands
|
||||||
- `Makefile` with development, quality, and diagnostic commands
|
- Configurable open-with menu order via `config.toml`:
|
||||||
- `instructions-agent.md` for AI coding agent guidance
|
- `open_with.files`
|
||||||
- `.env.example` for environment-based configuration
|
- `open_with.directories`
|
||||||
- README with setup, workflow, and template reuse instructions
|
- Automatic creation of opener config template when `config.toml` is missing
|
||||||
- MIT license
|
- New `opencode` opener support for directory actions
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Filter label highlighting now works consistently in both tree and history filter modes
|
||||||
|
- UI color behavior is now explicitly normalized to avoid terminal color bleeding
|
||||||
|
- History selection and filtering logic aligned with reversed (most-recent-first) history rendering
|
||||||
|
|
||||||
|
## [0.2.6] - 2026-04-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Incremental filter workflow (`/`) with contextual Enter behavior:
|
||||||
|
- Directories navigate in-place
|
||||||
|
- Files open the app menu
|
||||||
|
- File visibility toggle (`a`) to switch between directories-only and mixed view
|
||||||
|
- Open-with app menu (`o`) with arrow-key selection (`↑`/`↓`, `Enter`, `Esc`)
|
||||||
|
- New opener support for `nano`, `less`, and `bat` for file actions
|
||||||
|
- Blocking terminal app execution with UI state restore after exit
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- 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
|
||||||
|
|||||||
158
README.md
158
README.md
@@ -1,46 +1,151 @@
|
|||||||
# 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)
|
||||||
|
- 📜 Persistent history between sessions (`h` history mode, `b` back, `f` forward)
|
||||||
|
- 🔎 Incremental filter mode (`/`, type to filter, `Backspace`, `Esc`)
|
||||||
|
- 🧭 History filter mode (`/` inside history) with most-recent-first ordering
|
||||||
|
- 📄 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`)
|
||||||
|
- 🧩 Configurable app menu via `config.toml` with automatic template creation
|
||||||
|
- 🛠 Development snapshot in header + detailed info panel (`i`)
|
||||||
|
- 👀 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
|
||||||
|
- `i`: Toggle development info panel
|
||||||
|
- `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 (most recent at top)
|
||||||
|
- `Enter`: Jump to selected history path
|
||||||
|
- `Esc` or `h`: Exit history mode
|
||||||
|
- `PgUp` / `PgDn`, `Home` / `End`: Fast history navigation
|
||||||
|
- `/`: Enter history filter mode
|
||||||
|
|
||||||
|
History filter mode:
|
||||||
|
|
||||||
|
- Type text: Filter history entries in real time (case-insensitive)
|
||||||
|
- `↑` / `↓`: Move inside filtered history results
|
||||||
|
- `Enter`: Jump to selected history path
|
||||||
|
- `Esc`: Exit history filter mode and clear query
|
||||||
|
- `Backspace`: Remove last filter character
|
||||||
|
|
||||||
|
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`, `opencode`, `nvim`, `nano`, `less`, `bat` (as applicable)
|
||||||
|
|
||||||
|
## Opener Configuration
|
||||||
|
|
||||||
|
`cd-browser` reads opener settings from:
|
||||||
|
|
||||||
|
- macOS/Linux: `~/.config/cd-browser/config.toml`
|
||||||
|
- Windows: `%APPDATA%/cd-browser/config.toml`
|
||||||
|
|
||||||
|
If the file does not exist, `cd-browser` creates it automatically with defaults.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[open_with]
|
||||||
|
files = ["code", "open", "antigravity", "nvim", "nano", "less", "bat"]
|
||||||
|
directories = ["code", "opencode", "antigravity", "nvim"]
|
||||||
|
```
|
||||||
|
|
||||||
|
You can reorder or remove entries to customize app priority.
|
||||||
|
|
||||||
|
## 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 +182,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 +199,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
|
||||||
|
|
||||||
|
|||||||
219
docs/ai-execution-log.md
Normal file
219
docs/ai-execution-log.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
|
||||||
|
# AI Execution Log
|
||||||
|
|
||||||
|
This file stores the **full execution traces** of AI-assisted tasks.
|
||||||
|
|
||||||
|
Unlike:
|
||||||
|
- `docs/ai-worklog.md` → concise technical task summaries
|
||||||
|
- `docs/ai-prompts.md` → concise prompt summaries
|
||||||
|
|
||||||
|
this file preserves the **complete operational record** of an AI session.
|
||||||
|
|
||||||
|
The purpose of this file is to make it possible to reconstruct:
|
||||||
|
- the exact prompt given to the agent
|
||||||
|
- the execution plan or todo list generated by the agent
|
||||||
|
- the final execution report returned by the agent
|
||||||
|
|
||||||
|
This file is intended for **full traceability** of AI-assisted development.
|
||||||
|
|
||||||
|
Entries must be appended in chronological order.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard Entry Structure
|
||||||
|
|
||||||
|
Each execution entry should follow this structure:
|
||||||
|
|
||||||
|
```md
|
||||||
|
## BUILD-YYYYMMDD-XXX
|
||||||
|
|
||||||
|
Date: YYYY-MM-DD HH:MM
|
||||||
|
Agent: OpenCode
|
||||||
|
Task Type: BUILD
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Prompt Provided to Agent
|
||||||
|
|
||||||
|
<full prompt text>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Agent Todos
|
||||||
|
|
||||||
|
# Todos
|
||||||
|
[ ] item 1
|
||||||
|
[ ] item 2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Agent Execution Report
|
||||||
|
|
||||||
|
<final report returned by the agent>
|
||||||
|
```
|
||||||
|
|
||||||
|
For plan tasks, use:
|
||||||
|
|
||||||
|
```md
|
||||||
|
## PLAN-YYYYMMDD-XXX
|
||||||
|
|
||||||
|
Date: YYYY-MM-DD HH:MM
|
||||||
|
Agent: OpenCode
|
||||||
|
Task Type: PLAN
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Logging Rules
|
||||||
|
|
||||||
|
When a significant AI task is executed, this file should record:
|
||||||
|
|
||||||
|
1. The prompt that was actually given to the agent.
|
||||||
|
2. The todo or execution plan shown by the agent.
|
||||||
|
3. The final summary or execution report returned by the agent.
|
||||||
|
|
||||||
|
Do not store private chain-of-thought.
|
||||||
|
Only store user-visible execution artifacts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relationship With Other Logs
|
||||||
|
|
||||||
|
- `docs/ai-prompts.md` stores a concise summary of the prompt.
|
||||||
|
- `docs/ai-worklog.md` stores a concise technical summary of what was done.
|
||||||
|
- `docs/ai-execution-log.md` stores the complete visible execution trace.
|
||||||
|
|
||||||
|
Together, the three files provide:
|
||||||
|
|
||||||
|
1. Prompt intent
|
||||||
|
2. Technical result
|
||||||
|
3. Full execution trace
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## First Use
|
||||||
|
|
||||||
|
The next significant OpenCode task should append the first real execution entry to this file using the structure above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BUILD-20260316-005
|
||||||
|
|
||||||
|
Date: 2026-03-16 02:51
|
||||||
|
Agent: OpenCode
|
||||||
|
Task Type: BUILD
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Prompt Provided to Agent
|
||||||
|
|
||||||
|
Task Type:
|
||||||
|
BUILD
|
||||||
|
|
||||||
|
Task ID:
|
||||||
|
BUILD-20260316-005
|
||||||
|
|
||||||
|
Date:
|
||||||
|
2026-03-16 02:51
|
||||||
|
|
||||||
|
Objective:
|
||||||
|
Finalize AI logging framework and repository workflow updates
|
||||||
|
|
||||||
|
Context:
|
||||||
|
The repository contains pending changes related to the AI logging framework, execution logging, and AI task workflow. These changes introduce a three-level logging system (`ai-worklog.md`, `ai-prompts.md`, and `ai-execution-log.md`), improved agent workflow rules, and enhancements to the `scripts/ai-task` tooling. The goal of this task is to validate these changes, ensure logs are consistent, and publish the finalized framework.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- instructions-agent.md
|
||||||
|
- AGENT_WORKFLOW.md
|
||||||
|
- AI_TASK_TEMPLATE.md
|
||||||
|
- scripts/ai-task
|
||||||
|
- docs/ai-worklog.md
|
||||||
|
- docs/ai-prompts.md
|
||||||
|
- docs/ai-execution-log.md
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
1. Inspect the current working tree and confirm the changes in scope.
|
||||||
|
2. Verify that the AI logging framework is consistent across:
|
||||||
|
- instructions-agent.md
|
||||||
|
- AGENT_WORKFLOW.md
|
||||||
|
- AI_TASK_TEMPLATE.md
|
||||||
|
- scripts/ai-task
|
||||||
|
3. Ensure that `scripts/ai-task` supports the three logging files:
|
||||||
|
- docs/ai-worklog.md
|
||||||
|
- docs/ai-prompts.md
|
||||||
|
- docs/ai-execution-log.md
|
||||||
|
4. Append a new AI BUILD ENTRY to `docs/ai-worklog.md`.
|
||||||
|
5. Append a new BUILD PROMPT ENTRY to `docs/ai-prompts.md`.
|
||||||
|
6. Append a new execution entry to `docs/ai-execution-log.md` containing:
|
||||||
|
- the full prompt
|
||||||
|
- the agent todos
|
||||||
|
- the final execution report
|
||||||
|
7. Ensure all three logs remain in chronological order.
|
||||||
|
8. Stage all modified files within the defined scope.
|
||||||
|
9. Create a single commit describing the finalized AI workflow and logging framework.
|
||||||
|
10. Push the commit to the current branch.
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
- follow AGENT_WORKFLOW.md
|
||||||
|
- keep scope limited to the files listed above
|
||||||
|
- preserve existing project behavior unless explicitly changed
|
||||||
|
- logs must be written before creating the commit
|
||||||
|
- logs must be included in the same commit
|
||||||
|
|
||||||
|
Testing:
|
||||||
|
- run `make fix`
|
||||||
|
- run `make quality`
|
||||||
|
- verify that `scripts/ai-task` still generates a valid BUILD task prompt
|
||||||
|
- verify that log files remain chronologically ordered
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
make fix
|
||||||
|
make quality
|
||||||
|
|
||||||
|
Logging requirements:
|
||||||
|
- append entry to docs/ai-worklog.md
|
||||||
|
- append entry to docs/ai-prompts.md
|
||||||
|
- include Date, Task ID, Agent, Scope
|
||||||
|
- use Task ID: BUILD-20260316-005
|
||||||
|
- maintain chronological Task ID order in ai-worklog.md, ai-prompts.md and ai-execution-log.md
|
||||||
|
- append entry to docs/ai-execution-log.md
|
||||||
|
|
||||||
|
Execution Logging requirements:
|
||||||
|
- append 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:
|
||||||
|
- create a single commit
|
||||||
|
- include all updated logs in the commit
|
||||||
|
- push the commit to the current branch
|
||||||
|
|
||||||
|
Expected Report:
|
||||||
|
- files committed
|
||||||
|
- commit hash
|
||||||
|
- validation results
|
||||||
|
- confirmation that logs were updated before commit
|
||||||
|
- confirmation that push succeeded
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Agent Todos
|
||||||
|
|
||||||
|
# Todos
|
||||||
|
[x] Inspect scoped files and current logging framework changes
|
||||||
|
[x] Update worklog, prompt log, and execution log for BUILD-20260316-005
|
||||||
|
[x] Validate `scripts/ai-task` output and run `make fix` and `make quality`
|
||||||
|
[x] Stage scoped files, create single commit, and push branch
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Agent Execution Report
|
||||||
|
|
||||||
|
- Verified that `instructions-agent.md`, `AGENT_WORKFLOW.md`, `AI_TASK_TEMPLATE.md`, and `scripts/ai-task` describe the same three-level logging workflow.
|
||||||
|
- Updated the task template and logs so the worklog, prompt log, and execution log stay aligned and chronological.
|
||||||
|
- Confirmed `scripts/ai-task build "test task" src/app/ui.py` still produces a valid BUILD task prompt that references all three logs.
|
||||||
|
- Ran `make fix` and `make quality` successfully before commit creation.
|
||||||
|
- Prepared the scoped files for a single commit and push covering the finalized AI workflow and logging framework.
|
||||||
221
docs/ai-prompts.md
Normal file
221
docs/ai-prompts.md
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
# AI Prompts Log
|
||||||
|
|
||||||
|
This file stores the prompts that triggered significant AI-driven development tasks.
|
||||||
|
|
||||||
|
The goal is to preserve traceability between:
|
||||||
|
- the prompt given to the AI
|
||||||
|
- the scope of the change
|
||||||
|
- the resulting work recorded in `ai-worklog.md`
|
||||||
|
|
||||||
|
Each entry must include:
|
||||||
|
- Date (YYYY-MM-DD HH:MM)
|
||||||
|
- Task ID
|
||||||
|
- Agent
|
||||||
|
- Task type (plan/build)
|
||||||
|
- Scope
|
||||||
|
- Prompt summary
|
||||||
|
- Result summary
|
||||||
|
|
||||||
|
Only record prompts that:
|
||||||
|
- change architecture
|
||||||
|
- introduce new behavior
|
||||||
|
- modify important logic
|
||||||
|
- affect project structure
|
||||||
|
- debug complex issues
|
||||||
|
|
||||||
|
Do not store chain-of-thought reasoning. Only store the prompt intent and a concise result summary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BUILD PROMPT ENTRY
|
||||||
|
Date: 2026-03-15 18:05
|
||||||
|
Task ID: BUILD-20260315-001
|
||||||
|
Agent: OpenCode
|
||||||
|
Task: build
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- src/app/ui.py
|
||||||
|
- tests/test_ui.py
|
||||||
|
|
||||||
|
Prompt Summary:
|
||||||
|
Implement a dedicated history mode in `src/app/ui.py` so pressing `h` hides the tree, shows only history entries, supports Up/Down navigation, and uses Enter to select a history entry and return to the normal tree view.
|
||||||
|
|
||||||
|
Result Summary:
|
||||||
|
Added a dedicated history mode with its own selection state, removed the temporary overlay behavior, preserved existing `b`/`f` and normal tree navigation behavior, and added focused UI tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BUILD PROMPT ENTRY
|
||||||
|
Date: 2026-03-15 19:25
|
||||||
|
Task ID: BUILD-20260315-002
|
||||||
|
Agent: OpenCode
|
||||||
|
Task: build
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- instructions-agent.md
|
||||||
|
- docs/ai-worklog.md
|
||||||
|
- docs/ai-prompts.md
|
||||||
|
|
||||||
|
Prompt Summary:
|
||||||
|
Introduce a mandatory AI logging system by updating `instructions-agent.md` and adding repository logs for AI work entries and prompt summaries.
|
||||||
|
|
||||||
|
Result Summary:
|
||||||
|
Added enforceable AI logging rules to `instructions-agent.md`, created `docs/ai-worklog.md` and `docs/ai-prompts.md`, and established a traceable workflow for recording significant AI-assisted tasks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BUILD PROMPT ENTRY
|
||||||
|
Date: 2026-03-15 20:00
|
||||||
|
Task ID: BUILD-20260315-003
|
||||||
|
Agent: OpenCode
|
||||||
|
Task: build
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- instructions-agent.md
|
||||||
|
- docs/ai-worklog.md
|
||||||
|
- docs/ai-prompts.md
|
||||||
|
|
||||||
|
Prompt Summary:
|
||||||
|
Introduce a standard AI log entry format in `instructions-agent.md` and record the change in the repository AI logs.
|
||||||
|
|
||||||
|
Result Summary:
|
||||||
|
Added standardized `AI BUILD ENTRY` and `AI PLAN ENTRY` formats, improving consistency and long-term traceability of AI-assisted development.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BUILD PROMPT ENTRY
|
||||||
|
Date: 2026-03-15 21:35
|
||||||
|
Task ID: BUILD-20260315-004
|
||||||
|
Agent: OpenCode
|
||||||
|
Task: build
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- src/app/ui.py
|
||||||
|
- tests/test_ui.py
|
||||||
|
|
||||||
|
Prompt Summary:
|
||||||
|
Improve keyboard navigation in `src/app/ui.py` so Right expands or enters, Left collapses or goes to the parent, Enter confirms the selected destination, and ESC cancels back to the starting directory.
|
||||||
|
|
||||||
|
Result Summary:
|
||||||
|
Updated the tree-mode key handling in the terminal UI, preserved history mode behavior, and added focused UI tests for the new navigation rules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BUILD PROMPT ENTRY
|
||||||
|
Date: 2026-03-15 22:20
|
||||||
|
Task ID: BUILD-20260315-005
|
||||||
|
Agent: OpenCode
|
||||||
|
Task: build
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- src/app/ui.py
|
||||||
|
- tests/test_ui.py
|
||||||
|
|
||||||
|
Prompt Summary:
|
||||||
|
Improve vertical scrolling in `src/app/ui.py` so the selected entry always remains visible in tree mode and history mode when the list is taller than the screen.
|
||||||
|
|
||||||
|
Result Summary:
|
||||||
|
Added UI scroll offset handling for tree and history views, kept existing navigation behavior unchanged, and added focused tests for the scroll helper logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BUILD PROMPT ENTRY
|
||||||
|
Date: 2026-03-16 00:27
|
||||||
|
Task ID: BUILD-20260316-001
|
||||||
|
Agent: OpenCode
|
||||||
|
Task: build
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- src/app/ui.py
|
||||||
|
- tests/test_ui.py
|
||||||
|
|
||||||
|
Prompt Summary:
|
||||||
|
Refine terminal UI navigation, maintain dedicated history mode, and ensure scrolling continuity so selections remain visible.
|
||||||
|
|
||||||
|
Result Summary:
|
||||||
|
Updated terminal UI behavior, consolidated navigation improvements, and ensured consistent scrolling and selection visibility across tree and history modes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BUILD PROMPT ENTRY
|
||||||
|
Date: 2026-03-16 01:26
|
||||||
|
Task ID: BUILD-20260316-002
|
||||||
|
Agent: OpenCode
|
||||||
|
Task: build
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- instructions-agent.md
|
||||||
|
- docs/ai-worklog.md
|
||||||
|
- docs/ai-prompts.md
|
||||||
|
|
||||||
|
Prompt Summary:
|
||||||
|
Finalize the AI logging format system by requiring timestamped structured log entries and aligning the repository worklog and prompt log with the current logging framework.
|
||||||
|
|
||||||
|
Result Summary:
|
||||||
|
Updated the logging instructions, normalized the prompt log to the structured entry format, and added matching repository log entries for the finalized AI logging system.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BUILD PROMPT ENTRY
|
||||||
|
Date: 2026-03-16 01:34
|
||||||
|
Task ID: BUILD-20260316-003
|
||||||
|
Agent: OpenCode
|
||||||
|
Task: build
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- AGENT.md
|
||||||
|
- AGENT_WORKFLOW.md
|
||||||
|
- AI_TASK_TEMPLATE.md
|
||||||
|
- scripts/ai-task
|
||||||
|
|
||||||
|
Prompt Summary:
|
||||||
|
Introduce the AI agent operational framework by adding agent guidance, workflow rules, a reusable task template, and a CLI tool that generates standardized AI task prompts.
|
||||||
|
|
||||||
|
Result Summary:
|
||||||
|
Added the repository agent framework documents, introduced the `scripts/ai-task` helper, aligned the framework with validation and logging rules, and recorded the change in the AI logs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BUILD PROMPT ENTRY
|
||||||
|
Date: 2026-03-16 01:45
|
||||||
|
Task ID: BUILD-20260316-004
|
||||||
|
Agent: OpenCode
|
||||||
|
Task: build
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- instructions-agent.md
|
||||||
|
- AGENT.md
|
||||||
|
- AGENT_WORKFLOW.md
|
||||||
|
- AI_TASK_TEMPLATE.md
|
||||||
|
- scripts/ai-task
|
||||||
|
- docs/ai-worklog.md
|
||||||
|
- docs/ai-prompts.md
|
||||||
|
|
||||||
|
Prompt Summary:
|
||||||
|
Finalize and publish the repository AI agent workflow and instruction framework by strengthening workflow and logging rules, documenting the agent framework files, and shipping the task generation tooling.
|
||||||
|
|
||||||
|
Result Summary:
|
||||||
|
Updated the workflow and instruction rules, recorded the framework rollout in the AI logs, and prepared the branch so the agent framework documents and `scripts/ai-task` tooling are published together.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BUILD PROMPT ENTRY
|
||||||
|
Date: 2026-03-16 02:51
|
||||||
|
Task ID: BUILD-20260316-005
|
||||||
|
Agent: OpenCode
|
||||||
|
Task: build
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- instructions-agent.md
|
||||||
|
- AGENT_WORKFLOW.md
|
||||||
|
- AI_TASK_TEMPLATE.md
|
||||||
|
- scripts/ai-task
|
||||||
|
- docs/ai-worklog.md
|
||||||
|
- docs/ai-prompts.md
|
||||||
|
- docs/ai-execution-log.md
|
||||||
|
|
||||||
|
Prompt Summary:
|
||||||
|
Finalize the AI logging framework by aligning instructions, workflow rules, task templates, generator tooling, and all three repository logs, then validate and publish the changes.
|
||||||
|
|
||||||
|
Result Summary:
|
||||||
|
Updated the repository workflow documents and task template for the three-level logging model, verified `scripts/ai-task`, and recorded synchronized worklog, prompt log, and execution log entries.
|
||||||
377
docs/ai-worklog.md
Normal file
377
docs/ai-worklog.md
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
# AI Worklog
|
||||||
|
|
||||||
|
This file records important AI‑assisted development actions.
|
||||||
|
|
||||||
|
The log is designed for long‑term traceability of AI‑driven development.
|
||||||
|
|
||||||
|
Each entry must include:
|
||||||
|
- Date (YYYY-MM-DD HH:MM)
|
||||||
|
- Task ID
|
||||||
|
- Agent
|
||||||
|
- Task type (plan/build)
|
||||||
|
- Objective
|
||||||
|
- Scope
|
||||||
|
- Files Modified / Files Inspected
|
||||||
|
- Key Decisions or Proposed Changes
|
||||||
|
- Validation
|
||||||
|
- Result
|
||||||
|
- Open Issues
|
||||||
|
|
||||||
|
Entries must remain chronological.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AI BUILD ENTRY
|
||||||
|
Date: 2026-03-15 18:10
|
||||||
|
Task ID: BUILD-20260315-001
|
||||||
|
Agent: OpenCode
|
||||||
|
Task: build
|
||||||
|
Objective: Replace the temporary history debug overlay with a dedicated history mode in the terminal UI.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- src/app/ui.py
|
||||||
|
- tests/test_ui.py
|
||||||
|
|
||||||
|
Files Modified:
|
||||||
|
- src/app/ui.py
|
||||||
|
- tests/test_ui.py
|
||||||
|
|
||||||
|
Key Decisions:
|
||||||
|
- Keep `b` and `f` unchanged.
|
||||||
|
- Add separate history-mode selection state inside the UI.
|
||||||
|
- Hide the tree while history mode is active.
|
||||||
|
- Use Enter to select a history entry and return to the tree view.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- make fix
|
||||||
|
- make quality
|
||||||
|
|
||||||
|
Result:
|
||||||
|
- History mode now shows only the history list and allows navigation and selection of past directories.
|
||||||
|
|
||||||
|
Open Issues:
|
||||||
|
- None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AI BUILD ENTRY
|
||||||
|
Date: 2026-03-15 19:30
|
||||||
|
Task ID: BUILD-20260315-002
|
||||||
|
Agent: OpenCode
|
||||||
|
Task: build
|
||||||
|
Objective: Introduce mandatory AI logging rules and repository logging workflow.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- instructions-agent.md
|
||||||
|
- docs/ai-worklog.md
|
||||||
|
- docs/ai-prompts.md
|
||||||
|
|
||||||
|
Files Modified:
|
||||||
|
- instructions-agent.md
|
||||||
|
- docs/ai-worklog.md
|
||||||
|
- docs/ai-prompts.md
|
||||||
|
|
||||||
|
Key Decisions:
|
||||||
|
- Require AI agents to log plan and build tasks.
|
||||||
|
- Separate logs for work results and prompts.
|
||||||
|
- Keep entries concise and user‑focused.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- not run
|
||||||
|
|
||||||
|
Result:
|
||||||
|
- Repository now enforces AI logging via instructions-agent.md.
|
||||||
|
|
||||||
|
Open Issues:
|
||||||
|
- None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AI BUILD ENTRY
|
||||||
|
Date: 2026-03-15 20:05
|
||||||
|
Task ID: BUILD-20260315-003
|
||||||
|
Agent: OpenCode
|
||||||
|
Task: build
|
||||||
|
Objective: Introduce the standard AI log entry format and improve logging consistency.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- instructions-agent.md
|
||||||
|
- docs/ai-worklog.md
|
||||||
|
- docs/ai-prompts.md
|
||||||
|
|
||||||
|
Files Modified:
|
||||||
|
- instructions-agent.md
|
||||||
|
- docs/ai-worklog.md
|
||||||
|
- docs/ai-prompts.md
|
||||||
|
|
||||||
|
Key Decisions:
|
||||||
|
- Define AI BUILD ENTRY and AI PLAN ENTRY blocks.
|
||||||
|
- Standardize structure for long‑term repository traceability.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- make fix (passed)
|
||||||
|
- make quality (passed)
|
||||||
|
|
||||||
|
Result:
|
||||||
|
- Repository now uses a standardized AI logging format.
|
||||||
|
|
||||||
|
Open Issues:
|
||||||
|
- None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AI BUILD ENTRY
|
||||||
|
Date: 2026-03-15 21:40
|
||||||
|
Task ID: BUILD-20260315-004
|
||||||
|
Agent: OpenCode
|
||||||
|
Task: build
|
||||||
|
Objective: Improve keyboard navigation behavior in the terminal UI for tree mode.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- src/app/ui.py
|
||||||
|
- tests/test_ui.py
|
||||||
|
|
||||||
|
Files Modified:
|
||||||
|
- src/app/ui.py
|
||||||
|
- tests/test_ui.py
|
||||||
|
|
||||||
|
Key Decisions:
|
||||||
|
- Right expands collapsed directories and enters expanded directories.
|
||||||
|
- Left collapses expanded directories or navigates to the parent directory.
|
||||||
|
- Enter confirms the selected path.
|
||||||
|
- ESC cancels and returns to the original directory.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- make fix (passed)
|
||||||
|
- make quality (passed)
|
||||||
|
|
||||||
|
Result:
|
||||||
|
- Tree navigation semantics improved with clearer expand, enter, and cancel behavior.
|
||||||
|
|
||||||
|
Open Issues:
|
||||||
|
- None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AI BUILD ENTRY
|
||||||
|
Date: 2026-03-15 22:25
|
||||||
|
Task ID: BUILD-20260315-005
|
||||||
|
Agent: OpenCode
|
||||||
|
Task: build
|
||||||
|
Objective: Implement vertical scrolling so the selected entry always remains visible.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- src/app/ui.py
|
||||||
|
- tests/test_ui.py
|
||||||
|
|
||||||
|
Files Modified:
|
||||||
|
- src/app/ui.py
|
||||||
|
- tests/test_ui.py
|
||||||
|
|
||||||
|
Key Decisions:
|
||||||
|
- Introduce scroll offsets in the UI state.
|
||||||
|
- Clamp offsets to keep the selected row visible.
|
||||||
|
- Apply the same mechanism to history mode.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- make fix (passed)
|
||||||
|
- make quality (passed)
|
||||||
|
|
||||||
|
Result:
|
||||||
|
- Tree and history views now scroll when navigating beyond the visible screen area.
|
||||||
|
|
||||||
|
Open Issues:
|
||||||
|
- None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AI BUILD ENTRY
|
||||||
|
Date: 2026-03-16 00:27
|
||||||
|
Task ID: BUILD-20260316-001
|
||||||
|
Agent: OpenCode
|
||||||
|
Task: build
|
||||||
|
Objective: Consolidate terminal UI navigation improvements and document updated behavior.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- src/app/ui.py
|
||||||
|
- tests/test_ui.py
|
||||||
|
- docs/ai-worklog.md
|
||||||
|
- docs/ai-prompts.md
|
||||||
|
|
||||||
|
Files Modified:
|
||||||
|
- src/app/ui.py
|
||||||
|
- tests/test_ui.py
|
||||||
|
- docs/ai-worklog.md
|
||||||
|
- docs/ai-prompts.md
|
||||||
|
|
||||||
|
Key Decisions:
|
||||||
|
- Maintain dedicated history mode.
|
||||||
|
- Refine tree-mode keyboard behavior.
|
||||||
|
- Keep UI logic isolated from navigator logic.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- make fix (passed)
|
||||||
|
- make quality (passed)
|
||||||
|
|
||||||
|
Result:
|
||||||
|
- Terminal UI now includes history mode, improved navigation semantics, and reliable vertical scrolling.
|
||||||
|
|
||||||
|
Open Issues:
|
||||||
|
- None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AI BUILD ENTRY
|
||||||
|
Date: 2026-03-16 01:26
|
||||||
|
Task ID: BUILD-20260316-002
|
||||||
|
Agent: OpenCode
|
||||||
|
Task: build
|
||||||
|
Objective: Finalize the AI logging format system and align repository log files with the structured logging framework.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- instructions-agent.md
|
||||||
|
- docs/ai-worklog.md
|
||||||
|
- docs/ai-prompts.md
|
||||||
|
|
||||||
|
Files Modified:
|
||||||
|
- instructions-agent.md
|
||||||
|
- docs/ai-worklog.md
|
||||||
|
- docs/ai-prompts.md
|
||||||
|
|
||||||
|
Key Decisions:
|
||||||
|
- Require timestamped log entries with date and time, Task ID, Agent, and Scope fields.
|
||||||
|
- Keep the worklog and prompt log aligned with the current structured logging framework.
|
||||||
|
- Normalize prompt logging into a single consistent `BUILD PROMPT ENTRY` format.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- make fix (passed)
|
||||||
|
- make quality (passed)
|
||||||
|
|
||||||
|
Result:
|
||||||
|
- The AI logging system now uses consistent structured entries across repository instructions, worklog history, and prompt history.
|
||||||
|
|
||||||
|
Open Issues:
|
||||||
|
- None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AI BUILD ENTRY
|
||||||
|
Date: 2026-03-16 01:34
|
||||||
|
Task ID: BUILD-20260316-003
|
||||||
|
Agent: OpenCode
|
||||||
|
Task: build
|
||||||
|
Objective: Introduce the AI agent operational framework and task generation tooling for standardized AI-assisted repository work.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- AGENT.md
|
||||||
|
- AGENT_WORKFLOW.md
|
||||||
|
- AI_TASK_TEMPLATE.md
|
||||||
|
- scripts/ai-task
|
||||||
|
|
||||||
|
Files Modified:
|
||||||
|
- AGENT.md
|
||||||
|
- AGENT_WORKFLOW.md
|
||||||
|
- AI_TASK_TEMPLATE.md
|
||||||
|
- scripts/ai-task
|
||||||
|
- docs/ai-worklog.md
|
||||||
|
- docs/ai-prompts.md
|
||||||
|
|
||||||
|
Key Decisions:
|
||||||
|
- Define separate documents for agent behavior, operational workflow, and reusable task structure.
|
||||||
|
- Keep the task generator focused on producing standardized PLAN and BUILD prompts with task IDs and validation requirements.
|
||||||
|
- Align the new framework with the repository logging rules and validation workflow.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- scripts/ai-task build "test task" src/app/ui.py (passed)
|
||||||
|
- make fix (passed)
|
||||||
|
- make quality (passed)
|
||||||
|
|
||||||
|
Result:
|
||||||
|
- The repository now includes a documented AI agent framework and an executable helper tool for generating standardized AI task prompts.
|
||||||
|
|
||||||
|
Open Issues:
|
||||||
|
- None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AI BUILD ENTRY
|
||||||
|
Date: 2026-03-16 01:45
|
||||||
|
Task ID: BUILD-20260316-004
|
||||||
|
Agent: OpenCode
|
||||||
|
Task: build
|
||||||
|
Objective: Finalize and publish the AI agent workflow, instruction framework, and task tooling updates.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- instructions-agent.md
|
||||||
|
- AGENT.md
|
||||||
|
- AGENT_WORKFLOW.md
|
||||||
|
- AI_TASK_TEMPLATE.md
|
||||||
|
- scripts/ai-task
|
||||||
|
- docs/ai-worklog.md
|
||||||
|
- docs/ai-prompts.md
|
||||||
|
|
||||||
|
Files Modified:
|
||||||
|
- instructions-agent.md
|
||||||
|
- AGENT_WORKFLOW.md
|
||||||
|
- docs/ai-worklog.md
|
||||||
|
- docs/ai-prompts.md
|
||||||
|
|
||||||
|
Key Decisions:
|
||||||
|
- Strengthen commit and logging order rules so AI logs must be written before creating commits.
|
||||||
|
- Keep the new agent framework documents and task tooling aligned with the repository validation and traceability model.
|
||||||
|
- Publish the previously prepared framework files together with the finalized logging guidance on the current branch.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- make fix (passed)
|
||||||
|
- make quality (passed)
|
||||||
|
|
||||||
|
Result:
|
||||||
|
- The repository now has a finalized AI instruction and workflow framework, standardized task tooling, and matching logs documenting the framework rollout.
|
||||||
|
|
||||||
|
Open Issues:
|
||||||
|
- None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AI BUILD ENTRY
|
||||||
|
Date: 2026-03-16 02:51
|
||||||
|
Task ID: BUILD-20260316-005
|
||||||
|
Agent: OpenCode
|
||||||
|
Task: build
|
||||||
|
Objective: Finalize the three-level AI logging framework and repository workflow updates.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- instructions-agent.md
|
||||||
|
- AGENT_WORKFLOW.md
|
||||||
|
- AI_TASK_TEMPLATE.md
|
||||||
|
- scripts/ai-task
|
||||||
|
- docs/ai-worklog.md
|
||||||
|
- docs/ai-prompts.md
|
||||||
|
- docs/ai-execution-log.md
|
||||||
|
|
||||||
|
Files Modified:
|
||||||
|
- instructions-agent.md
|
||||||
|
- AGENT_WORKFLOW.md
|
||||||
|
- AI_TASK_TEMPLATE.md
|
||||||
|
- scripts/ai-task
|
||||||
|
- docs/ai-worklog.md
|
||||||
|
- docs/ai-prompts.md
|
||||||
|
- docs/ai-execution-log.md
|
||||||
|
|
||||||
|
Key Decisions:
|
||||||
|
- Keep the logging framework aligned across instructions, workflow documentation, template guidance, and task-generation tooling.
|
||||||
|
- Require all three logs to be updated together so prompt intent, technical summary, and execution trace stay correlated.
|
||||||
|
- Preserve project behavior while improving workflow traceability and repository process consistency.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- scripts/ai-task build "test task" src/app/ui.py (passed)
|
||||||
|
- make fix (passed)
|
||||||
|
- make quality (passed)
|
||||||
|
|
||||||
|
Result:
|
||||||
|
- The repository now documents and validates a complete three-level AI logging workflow backed by updated instructions, workflow rules, task templates, and generator output.
|
||||||
|
|
||||||
|
Open Issues:
|
||||||
|
- None
|
||||||
|
|
||||||
|
---
|
||||||
BIN
docs/demo.gif
Normal file
BIN
docs/demo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 469 KiB |
34
docs/logo-galicia-ansi-demo.zsh
Executable file
34
docs/logo-galicia-ansi-demo.zsh
Executable file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
set +H
|
||||||
|
|
||||||
|
blue=$'\033[38;2;0;114;206m'
|
||||||
|
white=$'\033[38;2;245;245;245m'
|
||||||
|
reset=$'\033[0m'
|
||||||
|
|
||||||
|
print_line() {
|
||||||
|
local r="$1"
|
||||||
|
local line="$2"
|
||||||
|
local out=""
|
||||||
|
local c ch center band
|
||||||
|
|
||||||
|
center=$((6 + r*12))
|
||||||
|
band=6
|
||||||
|
|
||||||
|
for ((c=1; c<=${#line}; c++)); do
|
||||||
|
ch="${line[c]}"
|
||||||
|
if [[ "$ch" == " " ]]; then
|
||||||
|
out+=" "
|
||||||
|
elif (( c-1 >= center-band && c-1 <= center+band )); then
|
||||||
|
out+="${blue}${ch}${reset}"
|
||||||
|
else
|
||||||
|
out+="${white}${ch}${reset}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
print -r -- "$out"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_line 0 " █ ███ "
|
||||||
|
print_line 1 " █ █ █ ██ ██ "
|
||||||
|
print_line 2 " ███ ███ ███ █/█ ██ █ █ █_ █__█ █/█"
|
||||||
|
print_line 3 "█ █ █ ███ █ █ █ █ █ █ █ █ █ █ █"
|
||||||
|
print_line 4 " ███ ███ ███ █ ██ █ █ ██ ██ █"
|
||||||
@@ -1,61 +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.
|
|
||||||
@@ -4,17 +4,31 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "cd-browser"
|
name = "cd-browser"
|
||||||
version = "0.1.0"
|
version = "0.3.0"
|
||||||
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"]
|
||||||
|
|||||||
149
scripts/ai-task
Executable file
149
scripts/ai-task
Executable file
@@ -0,0 +1,149 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# ai-task
|
||||||
|
# Helper CLI to generate AI task prompts based on AI_TASK_TEMPLATE.md
|
||||||
|
# Usage:
|
||||||
|
# scripts/ai-task build "Objective text" [scope...]
|
||||||
|
# scripts/ai-task plan "Objective text" [scope...]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
TYPE="$1"
|
||||||
|
OBJECTIVE="$2"
|
||||||
|
shift 2 || true
|
||||||
|
SCOPE=("$@")
|
||||||
|
|
||||||
|
if [[ -z "$TYPE" || -z "$OBJECTIVE" ]]; then
|
||||||
|
echo "Usage: ai-task <plan|build> \"Objective\" [scope files...]"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TYPE_LOWER=$(echo "$TYPE" | tr '[:upper:]' '[:lower:]')
|
||||||
|
TYPE_UPPER=$(echo "$TYPE_LOWER" | tr '[:lower:]' '[:upper:]')
|
||||||
|
|
||||||
|
DATE=$(date "+%Y-%m-%d %H:%M")
|
||||||
|
DAY=$(date "+%Y%m%d")
|
||||||
|
|
||||||
|
case "$TYPE_LOWER" in
|
||||||
|
build)
|
||||||
|
PREFIX="BUILD"
|
||||||
|
;;
|
||||||
|
plan)
|
||||||
|
PREFIX="PLAN"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Error: task type must be 'plan' or 'build'"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
WORKLOG_FILE="docs/ai-worklog.md"
|
||||||
|
PROMPTS_FILE="docs/ai-prompts.md"
|
||||||
|
EXECUTION_LOG_FILE="docs/ai-execution-log.md"
|
||||||
|
|
||||||
|
check_task_id_order() {
|
||||||
|
local file="$1"
|
||||||
|
local prefix="$2"
|
||||||
|
local previous=""
|
||||||
|
local current=""
|
||||||
|
|
||||||
|
[[ -f "$file" ]] || return 0
|
||||||
|
|
||||||
|
while IFS= read -r current; do
|
||||||
|
if [[ -n "$previous" && "$current" < "$previous" ]]; then
|
||||||
|
echo "Error: task IDs in $file are out of chronological order for prefix $prefix" >&2
|
||||||
|
echo "Previous: $previous" >&2
|
||||||
|
echo "Current: $current" >&2
|
||||||
|
echo "Please fix the log order before generating a new task." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
previous="$current"
|
||||||
|
done < <(grep -Eo "${prefix}-[0-9]{8}-[0-9]{3}" "$file" || true)
|
||||||
|
}
|
||||||
|
|
||||||
|
check_task_id_order "$WORKLOG_FILE" "$PREFIX"
|
||||||
|
check_task_id_order "$PROMPTS_FILE" "$PREFIX"
|
||||||
|
check_task_id_order "$EXECUTION_LOG_FILE" "$PREFIX"
|
||||||
|
|
||||||
|
LAST_SEQ=0
|
||||||
|
|
||||||
|
if [[ -f "$WORKLOG_FILE" ]]; then
|
||||||
|
LAST_MATCH=$(grep -Eo "${PREFIX}-${DAY}-[0-9]{3}" "$WORKLOG_FILE" | tail -n 1 || true)
|
||||||
|
if [[ -n "$LAST_MATCH" ]]; then
|
||||||
|
LAST_SEQ=${LAST_MATCH##*-}
|
||||||
|
LAST_SEQ=$((10#$LAST_SEQ))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
NEXT_SEQ=$(printf "%03d" $((LAST_SEQ + 1)))
|
||||||
|
TASK_ID="${PREFIX}-${DAY}-${NEXT_SEQ}"
|
||||||
|
|
||||||
|
# Build scope block
|
||||||
|
cat <<EOF
|
||||||
|
Task Type:
|
||||||
|
${TYPE_UPPER}
|
||||||
|
|
||||||
|
Task ID:
|
||||||
|
${TASK_ID}
|
||||||
|
|
||||||
|
Date:
|
||||||
|
${DATE}
|
||||||
|
|
||||||
|
Objective:
|
||||||
|
${OBJECTIVE}
|
||||||
|
|
||||||
|
Context:
|
||||||
|
<describe repository state if needed>
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ ${#SCOPE[@]} -gt 0 ]; then
|
||||||
|
for item in "${SCOPE[@]}"; do
|
||||||
|
printf '%s\n' "- ${item}"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
printf '%s\n' "- <fill scope>"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
<describe expected behavior>
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
- follow AGENT_WORKFLOW.md
|
||||||
|
- keep scope minimal
|
||||||
|
- preserve existing behavior unless explicitly changed
|
||||||
|
|
||||||
|
Testing:
|
||||||
|
<describe expected tests>
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
make fix
|
||||||
|
make quality
|
||||||
|
|
||||||
|
Logging requirements:
|
||||||
|
- append entry to docs/ai-worklog.md
|
||||||
|
- append entry to docs/ai-prompts.md
|
||||||
|
- include Date, Task ID, Agent, Scope
|
||||||
|
- use Task ID: ${TASK_ID}
|
||||||
|
- maintain chronological Task ID order in ai-worklog.md, ai-prompts.md and ai-execution-log.md
|
||||||
|
- append entry to docs/ai-execution-log.md
|
||||||
|
|
||||||
|
Execution Logging requirements:
|
||||||
|
- append 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:
|
||||||
|
- files changed
|
||||||
|
- behavior changes
|
||||||
|
- validation results
|
||||||
|
EOF
|
||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.history import SessionHistory
|
||||||
from app.navigator import Navigator
|
from app.navigator import Navigator
|
||||||
from app.ui import TerminalUI
|
from app.ui import TerminalUI
|
||||||
|
|
||||||
@@ -10,6 +11,6 @@ def run_cli(start_path: Path | None = None, ui: TerminalUI | None = None) -> Pat
|
|||||||
"""Run the interactive directory browser and return the selected path."""
|
"""Run the interactive directory browser and return the selected path."""
|
||||||
|
|
||||||
initial_path = (start_path or Path.cwd()).expanduser().resolve()
|
initial_path = (start_path or Path.cwd()).expanduser().resolve()
|
||||||
navigator = Navigator(initial_path)
|
navigator = Navigator(initial_path, history=SessionHistory.persistent())
|
||||||
terminal_ui = ui or TerminalUI()
|
terminal_ui = ui or TerminalUI()
|
||||||
return terminal_ui.run(navigator)
|
return terminal_ui.run(navigator)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
168
src/app/devinfo.py
Normal file
168
src/app/devinfo.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DevSnapshot:
|
||||||
|
"""Development context for a path."""
|
||||||
|
|
||||||
|
repo_root: Path | None
|
||||||
|
git_branch: str | None
|
||||||
|
git_dirty: bool
|
||||||
|
last_commit: str | None
|
||||||
|
stack: tuple[str, ...]
|
||||||
|
commands: tuple[str, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def header_summary(self) -> str:
|
||||||
|
git_part = "git:-"
|
||||||
|
if self.git_branch:
|
||||||
|
dirty_marker = "*" if self.git_dirty else ""
|
||||||
|
git_part = f"git:{self.git_branch}{dirty_marker}"
|
||||||
|
|
||||||
|
stack_part = ",".join(self.stack[:2]) if self.stack else "-"
|
||||||
|
return f"{git_part} stack:{stack_part}"
|
||||||
|
|
||||||
|
def detail_lines(self, current_path: Path) -> list[str]:
|
||||||
|
lines = [f"Current path: {current_path}"]
|
||||||
|
if self.repo_root is None:
|
||||||
|
lines.append("Git: not a repository")
|
||||||
|
else:
|
||||||
|
dirty_text = "dirty" if self.git_dirty else "clean"
|
||||||
|
lines.append(f"Git root: {self.repo_root}")
|
||||||
|
lines.append(f"Git branch: {self.git_branch or '-'} ({dirty_text})")
|
||||||
|
if self.last_commit:
|
||||||
|
lines.append(f"Last commit: {self.last_commit}")
|
||||||
|
|
||||||
|
stack_text = ", ".join(self.stack) if self.stack else "-"
|
||||||
|
lines.append(f"Detected stack: {stack_text}")
|
||||||
|
|
||||||
|
if self.commands:
|
||||||
|
lines.append("Suggested commands:")
|
||||||
|
for command in self.commands:
|
||||||
|
lines.append(f" - {command}")
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
class DevInfoCache:
|
||||||
|
"""Simple TTL cache for development snapshots."""
|
||||||
|
|
||||||
|
def __init__(self, ttl_seconds: float = 3.0) -> None:
|
||||||
|
self._ttl_seconds = ttl_seconds
|
||||||
|
self._cached_path: Path | None = None
|
||||||
|
self._cached_at: float = 0.0
|
||||||
|
self._cached_snapshot: DevSnapshot | None = None
|
||||||
|
|
||||||
|
def get(self, path: Path) -> DevSnapshot:
|
||||||
|
resolved_path = path.expanduser().resolve()
|
||||||
|
now = time.monotonic()
|
||||||
|
if (
|
||||||
|
self._cached_snapshot is not None
|
||||||
|
and self._cached_path == resolved_path
|
||||||
|
and (now - self._cached_at) <= self._ttl_seconds
|
||||||
|
):
|
||||||
|
return self._cached_snapshot
|
||||||
|
|
||||||
|
snapshot = collect_dev_snapshot(resolved_path)
|
||||||
|
self._cached_path = resolved_path
|
||||||
|
self._cached_snapshot = snapshot
|
||||||
|
self._cached_at = now
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
def collect_dev_snapshot(path: Path) -> DevSnapshot:
|
||||||
|
"""Collect git and stack metadata for a path."""
|
||||||
|
|
||||||
|
repo_root = _git_repo_root(path)
|
||||||
|
git_branch: str | None = None
|
||||||
|
git_dirty = False
|
||||||
|
last_commit: str | None = None
|
||||||
|
|
||||||
|
if repo_root is not None:
|
||||||
|
git_branch = _run_command(
|
||||||
|
["git", "-C", str(repo_root), "rev-parse", "--abbrev-ref", "HEAD"]
|
||||||
|
)
|
||||||
|
status_output = _run_command(
|
||||||
|
["git", "-C", str(repo_root), "status", "--porcelain"]
|
||||||
|
)
|
||||||
|
git_dirty = bool(status_output)
|
||||||
|
last_commit = _run_command(
|
||||||
|
["git", "-C", str(repo_root), "log", "-1", "--pretty=format:%h %s"]
|
||||||
|
)
|
||||||
|
|
||||||
|
stack = _detect_stack(path)
|
||||||
|
return DevSnapshot(
|
||||||
|
repo_root=repo_root,
|
||||||
|
git_branch=git_branch,
|
||||||
|
git_dirty=git_dirty,
|
||||||
|
last_commit=last_commit,
|
||||||
|
stack=stack,
|
||||||
|
commands=_suggested_commands(stack, repo_root is not None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _git_repo_root(path: Path) -> Path | None:
|
||||||
|
result = _run_command(["git", "-C", str(path), "rev-parse", "--show-toplevel"])
|
||||||
|
if not result:
|
||||||
|
return None
|
||||||
|
return Path(result).expanduser().resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_stack(path: Path) -> tuple[str, ...]:
|
||||||
|
markers = (
|
||||||
|
("pyproject.toml", "python"),
|
||||||
|
("requirements.txt", "python"),
|
||||||
|
("package.json", "node"),
|
||||||
|
("go.mod", "go"),
|
||||||
|
("Cargo.toml", "rust"),
|
||||||
|
)
|
||||||
|
found: list[str] = []
|
||||||
|
for marker, stack_name in markers:
|
||||||
|
if (path / marker).exists() and stack_name not in found:
|
||||||
|
found.append(stack_name)
|
||||||
|
|
||||||
|
return tuple(found)
|
||||||
|
|
||||||
|
|
||||||
|
def _suggested_commands(stack: tuple[str, ...], has_git: bool) -> tuple[str, ...]:
|
||||||
|
commands: list[str] = []
|
||||||
|
if has_git:
|
||||||
|
commands.append("git status -sb")
|
||||||
|
|
||||||
|
if "python" in stack:
|
||||||
|
commands.extend(["pytest -q", "ruff check src tests"])
|
||||||
|
if "node" in stack:
|
||||||
|
commands.extend(["npm test", "npm run lint"])
|
||||||
|
if "go" in stack:
|
||||||
|
commands.append("go test ./...")
|
||||||
|
if "rust" in stack:
|
||||||
|
commands.append("cargo test")
|
||||||
|
|
||||||
|
return tuple(commands)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_command(command: list[str]) -> str | None:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
command,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=0.35,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
output = result.stdout.strip()
|
||||||
|
if not output:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return output
|
||||||
@@ -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."""
|
||||||
|
|
||||||
directory_path = path.expanduser().resolve()
|
entries = list_entries(path, show_hidden=show_hidden, include_files=False)
|
||||||
entries: list[DirectoryEntry] = []
|
return [
|
||||||
|
|
||||||
for child in directory_path.iterdir():
|
|
||||||
if child.is_dir():
|
|
||||||
entries.append(
|
|
||||||
DirectoryEntry(
|
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()
|
||||||
|
entries: list[FileSystemEntry] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
children = list(directory_path.iterdir())
|
||||||
|
except PermissionError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
for child in children:
|
||||||
|
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,
|
name=child.name,
|
||||||
path=child,
|
path=child,
|
||||||
has_children=has_subdirectories(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
|
||||||
|
|||||||
@@ -1,15 +1,46 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def default_history_path() -> Path:
|
||||||
|
"""Return the default on-disk history location."""
|
||||||
|
|
||||||
|
override = os.environ.get("CD_BROWSER_HISTORY_FILE")
|
||||||
|
if override:
|
||||||
|
return Path(override).expanduser()
|
||||||
|
|
||||||
|
if os.name == "nt":
|
||||||
|
base = Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming")))
|
||||||
|
else:
|
||||||
|
base = Path(os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local/state")))
|
||||||
|
|
||||||
|
return base / "cd-browser" / "history.txt"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class SessionHistory:
|
class SessionHistory:
|
||||||
"""In-memory session history for visited directories."""
|
"""In-memory session history for visited directories."""
|
||||||
|
|
||||||
_entries: list[Path] = field(default_factory=list)
|
_entries: list[Path] = field(default_factory=list)
|
||||||
_index: int = -1
|
_index: int = -1
|
||||||
|
storage_path: Path | None = None
|
||||||
|
max_entries: int = 500
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def persistent(
|
||||||
|
cls, storage_path: Path | None = None, max_entries: int = 500
|
||||||
|
) -> SessionHistory:
|
||||||
|
"""Create a history instance backed by an on-disk file."""
|
||||||
|
|
||||||
|
history = cls(
|
||||||
|
storage_path=storage_path or default_history_path(),
|
||||||
|
max_entries=max_entries,
|
||||||
|
)
|
||||||
|
history._load()
|
||||||
|
return history
|
||||||
|
|
||||||
def visit(self, path: Path) -> Path:
|
def visit(self, path: Path) -> Path:
|
||||||
"""Record a directory visit and discard forward history when needed."""
|
"""Record a directory visit and discard forward history when needed."""
|
||||||
@@ -23,7 +54,9 @@ class SessionHistory:
|
|||||||
self._entries = self._entries[: self._index + 1]
|
self._entries = self._entries[: self._index + 1]
|
||||||
|
|
||||||
self._entries.append(resolved_path)
|
self._entries.append(resolved_path)
|
||||||
|
self._trim_to_limit()
|
||||||
self._index = len(self._entries) - 1
|
self._index = len(self._entries) - 1
|
||||||
|
self._persist()
|
||||||
return resolved_path
|
return resolved_path
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -66,3 +99,48 @@ class SessionHistory:
|
|||||||
"""Return the full history as an immutable sequence."""
|
"""Return the full history as an immutable sequence."""
|
||||||
|
|
||||||
return tuple(self._entries)
|
return tuple(self._entries)
|
||||||
|
|
||||||
|
def _load(self) -> None:
|
||||||
|
if self.storage_path is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
lines = self.storage_path.read_text(encoding="utf-8").splitlines()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
|
||||||
|
loaded_entries: list[Path] = []
|
||||||
|
for line in lines:
|
||||||
|
raw = line.strip()
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
|
||||||
|
resolved = Path(raw).expanduser().resolve()
|
||||||
|
if loaded_entries and loaded_entries[-1] == resolved:
|
||||||
|
continue
|
||||||
|
|
||||||
|
loaded_entries.append(resolved)
|
||||||
|
|
||||||
|
self._entries = loaded_entries
|
||||||
|
self._trim_to_limit()
|
||||||
|
self._index = len(self._entries) - 1
|
||||||
|
|
||||||
|
def _persist(self) -> None:
|
||||||
|
if self.storage_path is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
contents = "".join(f"{entry}\n" for entry in self._entries)
|
||||||
|
self.storage_path.write_text(contents, encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _trim_to_limit(self) -> None:
|
||||||
|
limit = max(1, self.max_entries)
|
||||||
|
if len(self._entries) <= limit:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._entries = self._entries[-limit:]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
311
src/app/opener.py
Normal file
311
src/app/opener.py
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tomllib
|
||||||
|
except ModuleNotFoundError: # pragma: no cover - python < 3.11
|
||||||
|
tomllib = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
LaunchMode = Literal["background", "blocking"]
|
||||||
|
DEFAULT_FILE_OPENERS = ("code", "open", "antigravity", "nvim", "nano", "less", "bat")
|
||||||
|
DEFAULT_DIRECTORY_OPENERS = ("code", "opencode", "antigravity", "nvim")
|
||||||
|
|
||||||
|
|
||||||
|
@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] = []
|
||||||
|
catalog = _options_catalog()
|
||||||
|
configured_option_ids = _configured_option_ids(is_directory, catalog)
|
||||||
|
|
||||||
|
for option_id in configured_option_ids:
|
||||||
|
option = catalog[option_id]
|
||||||
|
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 _options_catalog() -> dict[str, OpenWithOption]:
|
||||||
|
return {
|
||||||
|
"code": OpenWithOption(
|
||||||
|
label="VS Code",
|
||||||
|
command_template="code {path}",
|
||||||
|
executable="code",
|
||||||
|
launch_mode="background",
|
||||||
|
requires_gui=True,
|
||||||
|
),
|
||||||
|
"open": OpenWithOption(
|
||||||
|
label="Open (system)",
|
||||||
|
command_template=_system_open_template(),
|
||||||
|
executable=_system_open_executable(),
|
||||||
|
launch_mode="background",
|
||||||
|
requires_gui=True,
|
||||||
|
supports_directories=False,
|
||||||
|
),
|
||||||
|
"opencode": OpenWithOption(
|
||||||
|
label="OpenCode",
|
||||||
|
command_template="opencode {path}",
|
||||||
|
executable="opencode",
|
||||||
|
launch_mode="background",
|
||||||
|
supports_files=False,
|
||||||
|
),
|
||||||
|
"antigravity": OpenWithOption(
|
||||||
|
label="Antigravity",
|
||||||
|
command_template="antigravity {path}",
|
||||||
|
executable="antigravity",
|
||||||
|
launch_mode="background",
|
||||||
|
),
|
||||||
|
"nvim": OpenWithOption(
|
||||||
|
label="Neovim",
|
||||||
|
command_template="nvim {path}",
|
||||||
|
executable="nvim",
|
||||||
|
launch_mode="blocking",
|
||||||
|
),
|
||||||
|
"nano": OpenWithOption(
|
||||||
|
label="Nano",
|
||||||
|
command_template="nano {path}",
|
||||||
|
executable="nano",
|
||||||
|
launch_mode="blocking",
|
||||||
|
supports_directories=False,
|
||||||
|
),
|
||||||
|
"less": OpenWithOption(
|
||||||
|
label="Less",
|
||||||
|
command_template="less {path}",
|
||||||
|
executable="less",
|
||||||
|
launch_mode="blocking",
|
||||||
|
supports_directories=False,
|
||||||
|
),
|
||||||
|
"bat": OpenWithOption(
|
||||||
|
label="Bat",
|
||||||
|
command_template="bat {path}",
|
||||||
|
executable="bat",
|
||||||
|
launch_mode="blocking",
|
||||||
|
supports_directories=False,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_option_ids(
|
||||||
|
is_directory: bool, catalog: dict[str, OpenWithOption]
|
||||||
|
) -> list[str]:
|
||||||
|
config = _load_openers_config()
|
||||||
|
defaults = (
|
||||||
|
list(DEFAULT_DIRECTORY_OPENERS) if is_directory else list(DEFAULT_FILE_OPENERS)
|
||||||
|
)
|
||||||
|
configured = config["directories"] if is_directory else config["files"]
|
||||||
|
|
||||||
|
seen: set[str] = set()
|
||||||
|
option_ids: list[str] = []
|
||||||
|
for option_id in configured:
|
||||||
|
if option_id not in catalog or option_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(option_id)
|
||||||
|
option_ids.append(option_id)
|
||||||
|
|
||||||
|
if option_ids:
|
||||||
|
return option_ids
|
||||||
|
|
||||||
|
return [option_id for option_id in defaults if option_id in catalog]
|
||||||
|
|
||||||
|
|
||||||
|
def _openers_config_path() -> Path:
|
||||||
|
override = os.environ.get("CD_BROWSER_CONFIG_FILE")
|
||||||
|
if override:
|
||||||
|
return Path(override).expanduser()
|
||||||
|
|
||||||
|
if os.name == "nt":
|
||||||
|
base = Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming")))
|
||||||
|
else:
|
||||||
|
base = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")))
|
||||||
|
|
||||||
|
return base / "cd-browser" / "config.toml"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _load_openers_config() -> dict[str, list[str]]:
|
||||||
|
defaults = {
|
||||||
|
"files": list(DEFAULT_FILE_OPENERS),
|
||||||
|
"directories": list(DEFAULT_DIRECTORY_OPENERS),
|
||||||
|
}
|
||||||
|
config_path = _openers_config_path()
|
||||||
|
_ensure_default_config_file(config_path)
|
||||||
|
if tomllib is None or not config_path.exists():
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_data = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, tomllib.TOMLDecodeError):
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
if not isinstance(raw_data, dict):
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
open_with = raw_data.get("open_with")
|
||||||
|
if not isinstance(open_with, dict):
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
files = open_with.get("files")
|
||||||
|
directories = open_with.get("directories")
|
||||||
|
return {
|
||||||
|
"files": _normalize_ids(files, defaults["files"]),
|
||||||
|
"directories": _normalize_ids(directories, defaults["directories"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_ids(value: object, fallback: list[str]) -> list[str]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
normalized = [
|
||||||
|
item.strip() for item in value if isinstance(item, str) and item.strip()
|
||||||
|
]
|
||||||
|
return normalized if normalized else fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_default_config_file(path: Path) -> None:
|
||||||
|
if path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(_default_config_toml(), encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _default_config_toml() -> str:
|
||||||
|
file_ids = '", "'.join(DEFAULT_FILE_OPENERS)
|
||||||
|
directory_ids = '", "'.join(DEFAULT_DIRECTORY_OPENERS)
|
||||||
|
return (
|
||||||
|
"# cd-browser opener configuration\n"
|
||||||
|
"# Reorder or remove items to customize menu priority.\n"
|
||||||
|
"[open_with]\n"
|
||||||
|
f'files = ["{file_ids}"]\n'
|
||||||
|
f'directories = ["{directory_ids}"]\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_openers_config_cache() -> None:
|
||||||
|
_load_openers_config.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
1025
src/app/ui.py
1025
src/app/ui.py
File diff suppressed because it is too large
Load Diff
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
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.history import SessionHistory
|
from app.history import SessionHistory, default_history_path
|
||||||
|
|
||||||
|
|
||||||
def test_visit_records_current_directory(tmp_path: Path) -> None:
|
def test_visit_records_current_directory(tmp_path: Path) -> None:
|
||||||
@@ -82,3 +82,48 @@ def test_select_raises_for_invalid_index() -> None:
|
|||||||
|
|
||||||
with pytest.raises(IndexError):
|
with pytest.raises(IndexError):
|
||||||
history.select(0)
|
history.select(0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_persistent_history_loads_entries_from_disk(tmp_path: Path) -> None:
|
||||||
|
first = tmp_path / "first"
|
||||||
|
second = tmp_path / "second"
|
||||||
|
for directory in (first, second):
|
||||||
|
directory.mkdir()
|
||||||
|
history_file = tmp_path / "history.txt"
|
||||||
|
history_file.write_text(f"{first}\n{second}\n", encoding="utf-8")
|
||||||
|
|
||||||
|
history = SessionHistory.persistent(storage_path=history_file)
|
||||||
|
|
||||||
|
assert history.entries() == (first.resolve(), second.resolve())
|
||||||
|
assert history.current == second.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_persistent_history_visit_writes_to_disk_and_applies_limit(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
first = tmp_path / "first"
|
||||||
|
second = tmp_path / "second"
|
||||||
|
third = tmp_path / "third"
|
||||||
|
for directory in (first, second, third):
|
||||||
|
directory.mkdir()
|
||||||
|
history_file = tmp_path / "state" / "history.txt"
|
||||||
|
|
||||||
|
history = SessionHistory.persistent(storage_path=history_file, max_entries=2)
|
||||||
|
history.visit(first)
|
||||||
|
history.visit(second)
|
||||||
|
history.visit(third)
|
||||||
|
|
||||||
|
assert history.entries() == (second.resolve(), third.resolve())
|
||||||
|
assert history_file.read_text(encoding="utf-8").splitlines() == [
|
||||||
|
str(second.resolve()),
|
||||||
|
str(third.resolve()),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_history_path_uses_override_env(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
override = tmp_path / "custom-history.txt"
|
||||||
|
monkeypatch.setenv("CD_BROWSER_HISTORY_FILE", str(override))
|
||||||
|
|
||||||
|
assert default_history_path() == override
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
254
tests/test_opener.py
Normal file
254
tests/test_opener.py
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from subprocess import CompletedProcess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.opener import (
|
||||||
|
OpenWithOption,
|
||||||
|
_reset_openers_config_cache,
|
||||||
|
available_openers,
|
||||||
|
is_blocking_option,
|
||||||
|
launch_background_option,
|
||||||
|
launch_blocking_option,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_openers_config_cache() -> None:
|
||||||
|
_reset_openers_config_cache()
|
||||||
|
|
||||||
|
|
||||||
|
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_available_openers_respects_configured_order(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
config_path.write_text(
|
||||||
|
('[open_with]\nfiles = ["nvim", "code"]\ndirectories = ["opencode", "code"]\n'),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("CD_BROWSER_CONFIG_FILE", str(config_path))
|
||||||
|
monkeypatch.delenv("SSH_CONNECTION", raising=False)
|
||||||
|
monkeypatch.setattr("app.opener.sys.platform", "darwin")
|
||||||
|
|
||||||
|
available = {
|
||||||
|
"opencode": "/usr/local/bin/opencode",
|
||||||
|
"nvim": "/usr/local/bin/nvim",
|
||||||
|
"code": "/usr/local/bin/code",
|
||||||
|
}
|
||||||
|
monkeypatch.setattr("app.opener.shutil.which", lambda name: available.get(name))
|
||||||
|
|
||||||
|
folder = tmp_path / "project"
|
||||||
|
folder.mkdir()
|
||||||
|
file_path = tmp_path / "notes.txt"
|
||||||
|
file_path.write_text("hello", encoding="utf-8")
|
||||||
|
|
||||||
|
folder_labels = [option.label for option in available_openers(folder)]
|
||||||
|
file_labels = [option.label for option in available_openers(file_path)]
|
||||||
|
|
||||||
|
assert folder_labels == ["OpenCode", "VS Code"]
|
||||||
|
assert file_labels == ["Neovim", "VS Code"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_available_openers_invalid_config_falls_back_to_defaults(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
config_path.write_text("[open_with\n", encoding="utf-8")
|
||||||
|
monkeypatch.setenv("CD_BROWSER_CONFIG_FILE", str(config_path))
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
monkeypatch.setattr("app.opener.shutil.which", lambda name: available.get(name))
|
||||||
|
|
||||||
|
file_path = tmp_path / "notes.txt"
|
||||||
|
file_path.write_text("hello", encoding="utf-8")
|
||||||
|
labels = [option.label for option in available_openers(file_path)]
|
||||||
|
|
||||||
|
assert labels == ["Antigravity", "Neovim", "Nano", "Less", "Bat"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_available_openers_creates_default_config_when_missing(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
monkeypatch.setenv("CD_BROWSER_CONFIG_FILE", str(config_path))
|
||||||
|
monkeypatch.setenv("SSH_CONNECTION", "1")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.opener.shutil.which",
|
||||||
|
lambda name: (
|
||||||
|
"/usr/bin/true" if name in {"nvim", "nano", "less", "bat"} else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
file_path = tmp_path / "notes.txt"
|
||||||
|
file_path.write_text("hello", encoding="utf-8")
|
||||||
|
available_openers(file_path)
|
||||||
|
|
||||||
|
assert config_path.exists()
|
||||||
|
config_text = config_path.read_text(encoding="utf-8")
|
||||||
|
assert "[open_with]" in config_text
|
||||||
|
assert (
|
||||||
|
'files = ["code", "open", "antigravity", "nvim", "nano", "less", "bat"]'
|
||||||
|
in config_text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_available_openers_does_not_override_existing_config(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
original = '[open_with]\nfiles = ["nvim"]\ndirectories = ["opencode"]\n'
|
||||||
|
config_path.write_text(original, encoding="utf-8")
|
||||||
|
monkeypatch.setenv("CD_BROWSER_CONFIG_FILE", str(config_path))
|
||||||
|
monkeypatch.setenv("SSH_CONNECTION", "1")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.opener.shutil.which",
|
||||||
|
lambda name: "/usr/bin/true" if name in {"nvim", "opencode"} else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
file_path = tmp_path / "notes.txt"
|
||||||
|
file_path.write_text("hello", encoding="utf-8")
|
||||||
|
available_openers(file_path)
|
||||||
|
|
||||||
|
assert config_path.read_text(encoding="utf-8") == original
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
405
tests/test_ui.py
405
tests/test_ui.py
@@ -1,11 +1,18 @@
|
|||||||
|
import curses
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.navigator import Navigator, VisibleEntry
|
from app.navigator import Navigator, VisibleEntry
|
||||||
from app.ui import (
|
from app.ui import (
|
||||||
|
ESCAPE_KEY,
|
||||||
|
HistoryLine,
|
||||||
RenderLine,
|
RenderLine,
|
||||||
|
TerminalUI,
|
||||||
|
build_history_lines,
|
||||||
build_render_lines,
|
build_render_lines,
|
||||||
|
build_status_line,
|
||||||
|
clamp_scroll_offset,
|
||||||
format_entry,
|
format_entry,
|
||||||
move_selection,
|
move_selection,
|
||||||
open_terminal_streams,
|
open_terminal_streams,
|
||||||
@@ -47,6 +54,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()
|
||||||
@@ -65,6 +85,391 @@ def test_build_render_lines_marks_selected_entry(tmp_path: Path) -> None:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_history_lines_marks_selected_history_entry(tmp_path: Path) -> None:
|
||||||
|
first = tmp_path / "first"
|
||||||
|
second = tmp_path / "second"
|
||||||
|
|
||||||
|
assert build_history_lines((first, second), selected_index=1) == [
|
||||||
|
HistoryLine(text=str(first), is_selected=False),
|
||||||
|
HistoryLine(text=str(second), is_selected=True),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_clamp_scroll_offset_keeps_selection_visible_when_moving_down() -> None:
|
||||||
|
assert clamp_scroll_offset(5, scroll_offset=0, visible_count=3, total_count=10) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_clamp_scroll_offset_keeps_selection_visible_when_moving_up() -> None:
|
||||||
|
assert clamp_scroll_offset(1, scroll_offset=4, visible_count=3, total_count=10) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_clamp_scroll_offset_returns_zero_when_all_items_fit() -> None:
|
||||||
|
assert clamp_scroll_offset(2, scroll_offset=1, visible_count=5, total_count=3) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_history_mode_selects_current_history_entry(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
child = root / "child"
|
||||||
|
root.mkdir()
|
||||||
|
child.mkdir()
|
||||||
|
|
||||||
|
navigator = Navigator(root)
|
||||||
|
navigator.set_selected_index(1)
|
||||||
|
navigator.enter_selected_directory()
|
||||||
|
ui = TerminalUI()
|
||||||
|
|
||||||
|
ui._toggle_history_mode(navigator)
|
||||||
|
|
||||||
|
assert ui._history_mode is True
|
||||||
|
assert ui._history_selected_index == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_mode_enter_selects_entry_and_exits(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
first = root / "first"
|
||||||
|
second = root / "second"
|
||||||
|
root.mkdir()
|
||||||
|
first.mkdir()
|
||||||
|
second.mkdir()
|
||||||
|
|
||||||
|
navigator = Navigator(root)
|
||||||
|
navigator.set_selected_index(1)
|
||||||
|
navigator.enter_selected_directory()
|
||||||
|
navigator.go_to_parent_directory()
|
||||||
|
navigator.set_selected_index(2)
|
||||||
|
navigator.enter_selected_directory()
|
||||||
|
|
||||||
|
ui = TerminalUI()
|
||||||
|
ui._toggle_history_mode(navigator)
|
||||||
|
ui._handle_history_key(curses.KEY_DOWN, navigator)
|
||||||
|
ui._handle_history_key(10, navigator)
|
||||||
|
|
||||||
|
assert ui._history_mode is False
|
||||||
|
assert navigator.current_path == root.resolve()
|
||||||
|
assert navigator.selected_index == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_mode_renders_most_recent_entry_first(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
first = root / "first"
|
||||||
|
second = root / "second"
|
||||||
|
root.mkdir()
|
||||||
|
first.mkdir()
|
||||||
|
second.mkdir()
|
||||||
|
|
||||||
|
navigator = Navigator(root)
|
||||||
|
navigator.set_selected_index(1)
|
||||||
|
navigator.enter_selected_directory()
|
||||||
|
navigator.go_to_parent_directory()
|
||||||
|
navigator.set_selected_index(2)
|
||||||
|
navigator.enter_selected_directory()
|
||||||
|
|
||||||
|
ui = TerminalUI()
|
||||||
|
ui._toggle_history_mode(navigator)
|
||||||
|
lines, _selected_index = ui._build_history_lines_for_render(
|
||||||
|
navigator.history.entries()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert lines[0].text == str(second.resolve())
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_mode_slash_activates_history_filter() -> None:
|
||||||
|
ui = TerminalUI()
|
||||||
|
navigator = Navigator(Path.cwd())
|
||||||
|
ui._history_mode = True
|
||||||
|
|
||||||
|
ui._handle_history_key(ord("/"), navigator)
|
||||||
|
|
||||||
|
assert ui._history_filter_mode is True
|
||||||
|
assert ui._history_filter_query == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_filter_enter_selects_filtered_entry_and_exits_history(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
alpha = root / "alpha"
|
||||||
|
beta = root / "beta"
|
||||||
|
root.mkdir()
|
||||||
|
alpha.mkdir()
|
||||||
|
beta.mkdir()
|
||||||
|
|
||||||
|
navigator = Navigator(root)
|
||||||
|
navigator.set_selected_index(1)
|
||||||
|
navigator.enter_selected_directory()
|
||||||
|
navigator.go_to_parent_directory()
|
||||||
|
navigator.set_selected_index(2)
|
||||||
|
navigator.enter_selected_directory()
|
||||||
|
|
||||||
|
ui = TerminalUI()
|
||||||
|
ui._toggle_history_mode(navigator)
|
||||||
|
ui._history_filter_mode = True
|
||||||
|
ui._history_filter_query = "alpha"
|
||||||
|
|
||||||
|
ui._handle_history_key(10, navigator)
|
||||||
|
|
||||||
|
assert navigator.current_path == alpha.resolve()
|
||||||
|
assert ui._history_mode is False
|
||||||
|
assert ui._history_filter_mode is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_filter_escape_disables_only_history_filter(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
root.mkdir()
|
||||||
|
navigator = Navigator(root)
|
||||||
|
ui = TerminalUI()
|
||||||
|
ui._toggle_history_mode(navigator)
|
||||||
|
ui._history_filter_mode = True
|
||||||
|
ui._history_filter_query = "ro"
|
||||||
|
|
||||||
|
ui._handle_history_key(ESCAPE_KEY, navigator)
|
||||||
|
|
||||||
|
assert ui._history_mode is True
|
||||||
|
assert ui._history_filter_mode is False
|
||||||
|
assert ui._history_filter_query == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_mode_toggle_off_keeps_current_directory(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
root.mkdir()
|
||||||
|
navigator = Navigator(root)
|
||||||
|
ui = TerminalUI()
|
||||||
|
|
||||||
|
ui._toggle_history_mode(navigator)
|
||||||
|
ui._toggle_history_mode(navigator)
|
||||||
|
|
||||||
|
assert ui._history_mode is False
|
||||||
|
assert navigator.current_path == root.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_right_arrow_expands_collapsed_directory(tmp_path: Path) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
scripts = workspace / "scripts"
|
||||||
|
build = scripts / "build"
|
||||||
|
workspace.mkdir()
|
||||||
|
scripts.mkdir()
|
||||||
|
build.mkdir()
|
||||||
|
|
||||||
|
navigator = Navigator(workspace)
|
||||||
|
navigator.set_selected_index(1)
|
||||||
|
ui = TerminalUI()
|
||||||
|
|
||||||
|
assert ui._handle_tree_key(curses.KEY_RIGHT, navigator) is None
|
||||||
|
assert navigator.current_path == workspace.resolve()
|
||||||
|
assert [entry.name for entry in navigator.visible_entries] == [
|
||||||
|
"..",
|
||||||
|
"scripts",
|
||||||
|
"build",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_right_arrow_enters_already_expanded_directory(tmp_path: Path) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
scripts = workspace / "scripts"
|
||||||
|
build = scripts / "build"
|
||||||
|
workspace.mkdir()
|
||||||
|
scripts.mkdir()
|
||||||
|
build.mkdir()
|
||||||
|
|
||||||
|
navigator = Navigator(workspace)
|
||||||
|
navigator.set_selected_index(1)
|
||||||
|
navigator.expand_selected_directory()
|
||||||
|
ui = TerminalUI()
|
||||||
|
|
||||||
|
assert ui._handle_tree_key(curses.KEY_RIGHT, navigator) is None
|
||||||
|
assert navigator.current_path == scripts.resolve()
|
||||||
|
assert navigator.selected_index == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_left_arrow_goes_to_parent_when_selected_entry_is_collapsed(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
parent = tmp_path / "parent"
|
||||||
|
current = parent / "current"
|
||||||
|
child = current / "child"
|
||||||
|
parent.mkdir()
|
||||||
|
current.mkdir()
|
||||||
|
child.mkdir()
|
||||||
|
|
||||||
|
navigator = Navigator(current)
|
||||||
|
navigator.set_selected_index(1)
|
||||||
|
ui = TerminalUI()
|
||||||
|
|
||||||
|
assert ui._handle_tree_key(curses.KEY_LEFT, navigator) is None
|
||||||
|
assert navigator.current_path == parent.resolve()
|
||||||
|
assert navigator.selected_index == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_enter_returns_selected_path_without_navigating(tmp_path: Path) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
child = workspace / "child"
|
||||||
|
workspace.mkdir()
|
||||||
|
child.mkdir()
|
||||||
|
|
||||||
|
navigator = Navigator(workspace)
|
||||||
|
navigator.set_selected_index(1)
|
||||||
|
ui = TerminalUI()
|
||||||
|
|
||||||
|
result = ui._handle_tree_key(10, navigator)
|
||||||
|
|
||||||
|
assert result == child.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_build_status_line_shows_filter_off_when_filter_mode_is_disabled(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
root.mkdir()
|
||||||
|
navigator = Navigator(root)
|
||||||
|
|
||||||
|
status_line = build_status_line(
|
||||||
|
navigator,
|
||||||
|
filter_mode=False,
|
||||||
|
filter_query="",
|
||||||
|
dev_summary="git:- stack:-",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "filter: off" in status_line
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_status_line_shows_empty_filter_without_off_when_filter_is_active(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
root.mkdir()
|
||||||
|
navigator = Navigator(root)
|
||||||
|
|
||||||
|
status_line = build_status_line(
|
||||||
|
navigator,
|
||||||
|
filter_mode=True,
|
||||||
|
filter_query="",
|
||||||
|
dev_summary="git:- stack:-",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "filter: |" in status_line
|
||||||
|
|
||||||
|
|
||||||
|
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_info_mode_toggles_with_i_and_escape(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
root.mkdir()
|
||||||
|
navigator = Navigator(root)
|
||||||
|
ui = TerminalUI()
|
||||||
|
|
||||||
|
assert ui._info_mode is False
|
||||||
|
assert ui._handle_tree_key(ord("i"), navigator) is None
|
||||||
|
assert ui._info_mode is True
|
||||||
|
|
||||||
|
ui._handle_info_key(ESCAPE_KEY)
|
||||||
|
assert ui._info_mode is False
|
||||||
|
|
||||||
|
|
||||||
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