cd-browser 0.2.5__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cd_browser-0.2.5 → cd_browser-0.3.0}/PKG-INFO +96 -6
- cd_browser-0.3.0/README.md +232 -0
- {cd_browser-0.2.5 → cd_browser-0.3.0}/pyproject.toml +1 -1
- {cd_browser-0.2.5 → cd_browser-0.3.0}/src/app/cli.py +2 -1
- cd_browser-0.3.0/src/app/devinfo.py +168 -0
- {cd_browser-0.2.5 → cd_browser-0.3.0}/src/app/filesystem.py +39 -5
- cd_browser-0.3.0/src/app/history.py +146 -0
- {cd_browser-0.2.5 → cd_browser-0.3.0}/src/app/navigator.py +27 -8
- cd_browser-0.3.0/src/app/opener.py +311 -0
- cd_browser-0.3.0/src/app/ui.py +1158 -0
- {cd_browser-0.2.5 → cd_browser-0.3.0}/src/cd_browser.egg-info/PKG-INFO +96 -6
- {cd_browser-0.2.5 → cd_browser-0.3.0}/src/cd_browser.egg-info/SOURCES.txt +3 -0
- {cd_browser-0.2.5 → cd_browser-0.3.0}/tests/test_filesystem.py +29 -1
- {cd_browser-0.2.5 → cd_browser-0.3.0}/tests/test_history.py +46 -1
- {cd_browser-0.2.5 → cd_browser-0.3.0}/tests/test_navigator.py +18 -0
- cd_browser-0.3.0/tests/test_opener.py +254 -0
- cd_browser-0.3.0/tests/test_ui.py +485 -0
- cd_browser-0.2.5/README.md +0 -142
- cd_browser-0.2.5/src/app/history.py +0 -68
- cd_browser-0.2.5/src/app/ui.py +0 -344
- cd_browser-0.2.5/tests/test_ui.py +0 -236
- {cd_browser-0.2.5 → cd_browser-0.3.0}/LICENSE +0 -0
- {cd_browser-0.2.5 → cd_browser-0.3.0}/setup.cfg +0 -0
- {cd_browser-0.2.5 → cd_browser-0.3.0}/src/app/__init__.py +0 -0
- {cd_browser-0.2.5 → cd_browser-0.3.0}/src/app/config.py +0 -0
- {cd_browser-0.2.5 → cd_browser-0.3.0}/src/app/domain/__init__.py +0 -0
- {cd_browser-0.2.5 → cd_browser-0.3.0}/src/app/infrastructure/__init__.py +0 -0
- {cd_browser-0.2.5 → cd_browser-0.3.0}/src/app/logging_config.py +0 -0
- {cd_browser-0.2.5 → cd_browser-0.3.0}/src/app/main.py +0 -0
- {cd_browser-0.2.5 → cd_browser-0.3.0}/src/app/services/__init__.py +0 -0
- {cd_browser-0.2.5 → cd_browser-0.3.0}/src/app/utils/__init__.py +0 -0
- {cd_browser-0.2.5 → cd_browser-0.3.0}/src/cd_browser.egg-info/dependency_links.txt +0 -0
- {cd_browser-0.2.5 → cd_browser-0.3.0}/src/cd_browser.egg-info/entry_points.txt +0 -0
- {cd_browser-0.2.5 → cd_browser-0.3.0}/src/cd_browser.egg-info/requires.txt +0 -0
- {cd_browser-0.2.5 → cd_browser-0.3.0}/src/cd_browser.egg-info/top_level.txt +0 -0
- {cd_browser-0.2.5 → cd_browser-0.3.0}/src/cd_browser_post_install.py +0 -0
- {cd_browser-0.2.5 → cd_browser-0.3.0}/tests/test_cli.py +0 -0
- {cd_browser-0.2.5 → cd_browser-0.3.0}/tests/test_post_install.py +0 -0
- {cd_browser-0.2.5 → cd_browser-0.3.0}/tests/test_smoke.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cd-browser
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A fast keyboard-driven directory navigator for the terminal.
|
|
5
5
|
Author: Saky
|
|
6
6
|
Classifier: Development Status :: 3 - Alpha
|
|
@@ -50,12 +50,95 @@ Working in the terminal often means:
|
|
|
50
50
|
|
|
51
51
|
- ⌨️ Fully keyboard-driven workflow (`↑` / `↓` / `←` / `→`)
|
|
52
52
|
- 🌲 Expand and collapse directories (`→` to expand/enter, `←` to collapse/back)
|
|
53
|
-
- 📜
|
|
54
|
-
-
|
|
55
|
-
-
|
|
53
|
+
- 📜 Persistent history between sessions (`h` history mode, `b` back, `f` forward)
|
|
54
|
+
- 🔎 Incremental filter mode (`/`, type to filter, `Backspace`, `Esc`)
|
|
55
|
+
- 🧭 History filter mode (`/` inside history) with most-recent-first ordering
|
|
56
|
+
- 📄 Toggle file visibility (`a`) to switch between directories-only and mixed view
|
|
57
|
+
- ⚡ Fast jumps in large lists (`PgUp`/`PgDn`, `Home`/`End`, `g`/`G` in normal mode)
|
|
58
|
+
- 🚀 Contextual `open with` menu (`o`) with app detection based on your environment
|
|
59
|
+
- 🔁 Blocking terminal editors restore the UI state when they exit (`nvim`, `nano`, `less`, `bat`)
|
|
60
|
+
- 🧩 Configurable app menu via `config.toml` with automatic template creation
|
|
61
|
+
- 🛠 Development snapshot in header + detailed info panel (`i`)
|
|
62
|
+
- 👀 Toggle hidden entries with `.`
|
|
56
63
|
- 🖥 Native terminal interface
|
|
57
64
|
- 🔁 Works with Bash, Zsh and other shells
|
|
58
|
-
-
|
|
65
|
+
- ⚡ Returns the selected path to the shell (`Enter` to confirm, `Esc` to cancel)
|
|
66
|
+
|
|
67
|
+
## Controls
|
|
68
|
+
|
|
69
|
+
Normal mode:
|
|
70
|
+
|
|
71
|
+
- `↑` / `↓`: Move selection
|
|
72
|
+
- `→`: Expand directory, or enter when already expanded
|
|
73
|
+
- `←`: Collapse directory, or go to parent
|
|
74
|
+
- `Enter`: Confirm selected path and exit
|
|
75
|
+
- `Esc`: Cancel and return the original path
|
|
76
|
+
- `h`: Toggle history mode
|
|
77
|
+
- `b` / `f`: Back / forward in navigation history
|
|
78
|
+
- `.`: Toggle hidden entries
|
|
79
|
+
- `a`: Toggle file visibility
|
|
80
|
+
- `/`: Enter filter mode
|
|
81
|
+
- `o`: Open selected entry with an app
|
|
82
|
+
- `i`: Toggle development info panel
|
|
83
|
+
- `PgUp` / `PgDn`: Page jump
|
|
84
|
+
- `Home` / `End`: Jump to first/last entry
|
|
85
|
+
- `g` / `G`: Jump to first/last entry
|
|
86
|
+
|
|
87
|
+
Filter mode:
|
|
88
|
+
|
|
89
|
+
- Type text: Filter visible entries in real time (case-insensitive)
|
|
90
|
+
- `↑` / `↓`: Move inside filtered results
|
|
91
|
+
- `Enter`: Apply filter and perform contextual action:
|
|
92
|
+
- Directory: navigate into it without exiting `cd_`
|
|
93
|
+
- File: open app selection menu
|
|
94
|
+
- `Esc`: Exit filter mode and clear query
|
|
95
|
+
- `Backspace`: Remove last filter character
|
|
96
|
+
|
|
97
|
+
History mode:
|
|
98
|
+
|
|
99
|
+
- `↑` / `↓`: Move through history entries (most recent at top)
|
|
100
|
+
- `Enter`: Jump to selected history path
|
|
101
|
+
- `Esc` or `h`: Exit history mode
|
|
102
|
+
- `PgUp` / `PgDn`, `Home` / `End`: Fast history navigation
|
|
103
|
+
- `/`: Enter history filter mode
|
|
104
|
+
|
|
105
|
+
History filter mode:
|
|
106
|
+
|
|
107
|
+
- Type text: Filter history entries in real time (case-insensitive)
|
|
108
|
+
- `↑` / `↓`: Move inside filtered history results
|
|
109
|
+
- `Enter`: Jump to selected history path
|
|
110
|
+
- `Esc`: Exit history filter mode and clear query
|
|
111
|
+
- `Backspace`: Remove last filter character
|
|
112
|
+
|
|
113
|
+
Open with menu (`o`):
|
|
114
|
+
|
|
115
|
+
- `↑` / `↓`: Move between app options
|
|
116
|
+
- `Enter`: Launch selected app
|
|
117
|
+
- `Esc`: Cancel
|
|
118
|
+
|
|
119
|
+
The menu only shows apps available in your system (`PATH`) and context:
|
|
120
|
+
|
|
121
|
+
- GUI options (when GUI is available): `code`, system `open`/`xdg-open`
|
|
122
|
+
- Terminal options: `antigravity`, `opencode`, `nvim`, `nano`, `less`, `bat` (as applicable)
|
|
123
|
+
|
|
124
|
+
## Opener Configuration
|
|
125
|
+
|
|
126
|
+
`cd-browser` reads opener settings from:
|
|
127
|
+
|
|
128
|
+
- macOS/Linux: `~/.config/cd-browser/config.toml`
|
|
129
|
+
- Windows: `%APPDATA%/cd-browser/config.toml`
|
|
130
|
+
|
|
131
|
+
If the file does not exist, `cd-browser` creates it automatically with defaults.
|
|
132
|
+
|
|
133
|
+
Example:
|
|
134
|
+
|
|
135
|
+
```toml
|
|
136
|
+
[open_with]
|
|
137
|
+
files = ["code", "open", "antigravity", "nvim", "nano", "less", "bat"]
|
|
138
|
+
directories = ["code", "opencode", "antigravity", "nvim"]
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
You can reorder or remove entries to customize app priority.
|
|
59
142
|
|
|
60
143
|
## Quick Demo
|
|
61
144
|
|
|
@@ -126,7 +209,14 @@ Then use:
|
|
|
126
209
|
cd_
|
|
127
210
|
```
|
|
128
211
|
|
|
129
|
-
-
|
|
212
|
+
- Inside `cd_browser`, press `.` to toggle hidden entries.
|
|
213
|
+
|
|
214
|
+
## Platform Compatibility
|
|
215
|
+
|
|
216
|
+
Practical conclusion:
|
|
217
|
+
|
|
218
|
+
- Best current support: **macOS and Linux**
|
|
219
|
+
- **Windows is partially supported**, but still needs real-world validation in Windows terminal environments before being considered fully supported
|
|
130
220
|
|
|
131
221
|
## Uninstall
|
|
132
222
|
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# cd-browser
|
|
2
|
+
|
|
3
|
+
**Stop typing paths. Browse them.**
|
|
4
|
+
|
|
5
|
+
`cd-browser` is a fast keyboard-driven directory navigator for the terminal.
|
|
6
|
+
It lets you explore directory trees visually and jump to any folder instantly.
|
|
7
|
+
|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
Repository (full demo + docs): https://gitea.sakydogalo.es/saky/cd-browser
|
|
11
|
+
|
|
12
|
+
## Why cd-browser?
|
|
13
|
+
|
|
14
|
+
Working in the terminal often means:
|
|
15
|
+
|
|
16
|
+
- typing long directory paths
|
|
17
|
+
- navigating deep folder trees
|
|
18
|
+
- repeating `cd ..` multiple times
|
|
19
|
+
|
|
20
|
+
`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.
|
|
21
|
+
|
|
22
|
+
## Features
|
|
23
|
+
|
|
24
|
+
- ⌨️ Fully keyboard-driven workflow (`↑` / `↓` / `←` / `→`)
|
|
25
|
+
- 🌲 Expand and collapse directories (`→` to expand/enter, `←` to collapse/back)
|
|
26
|
+
- 📜 Persistent history between sessions (`h` history mode, `b` back, `f` forward)
|
|
27
|
+
- 🔎 Incremental filter mode (`/`, type to filter, `Backspace`, `Esc`)
|
|
28
|
+
- 🧭 History filter mode (`/` inside history) with most-recent-first ordering
|
|
29
|
+
- 📄 Toggle file visibility (`a`) to switch between directories-only and mixed view
|
|
30
|
+
- ⚡ Fast jumps in large lists (`PgUp`/`PgDn`, `Home`/`End`, `g`/`G` in normal mode)
|
|
31
|
+
- 🚀 Contextual `open with` menu (`o`) with app detection based on your environment
|
|
32
|
+
- 🔁 Blocking terminal editors restore the UI state when they exit (`nvim`, `nano`, `less`, `bat`)
|
|
33
|
+
- 🧩 Configurable app menu via `config.toml` with automatic template creation
|
|
34
|
+
- 🛠 Development snapshot in header + detailed info panel (`i`)
|
|
35
|
+
- 👀 Toggle hidden entries with `.`
|
|
36
|
+
- 🖥 Native terminal interface
|
|
37
|
+
- 🔁 Works with Bash, Zsh and other shells
|
|
38
|
+
- ⚡ Returns the selected path to the shell (`Enter` to confirm, `Esc` to cancel)
|
|
39
|
+
|
|
40
|
+
## Controls
|
|
41
|
+
|
|
42
|
+
Normal mode:
|
|
43
|
+
|
|
44
|
+
- `↑` / `↓`: Move selection
|
|
45
|
+
- `→`: Expand directory, or enter when already expanded
|
|
46
|
+
- `←`: Collapse directory, or go to parent
|
|
47
|
+
- `Enter`: Confirm selected path and exit
|
|
48
|
+
- `Esc`: Cancel and return the original path
|
|
49
|
+
- `h`: Toggle history mode
|
|
50
|
+
- `b` / `f`: Back / forward in navigation history
|
|
51
|
+
- `.`: Toggle hidden entries
|
|
52
|
+
- `a`: Toggle file visibility
|
|
53
|
+
- `/`: Enter filter mode
|
|
54
|
+
- `o`: Open selected entry with an app
|
|
55
|
+
- `i`: Toggle development info panel
|
|
56
|
+
- `PgUp` / `PgDn`: Page jump
|
|
57
|
+
- `Home` / `End`: Jump to first/last entry
|
|
58
|
+
- `g` / `G`: Jump to first/last entry
|
|
59
|
+
|
|
60
|
+
Filter mode:
|
|
61
|
+
|
|
62
|
+
- Type text: Filter visible entries in real time (case-insensitive)
|
|
63
|
+
- `↑` / `↓`: Move inside filtered results
|
|
64
|
+
- `Enter`: Apply filter and perform contextual action:
|
|
65
|
+
- Directory: navigate into it without exiting `cd_`
|
|
66
|
+
- File: open app selection menu
|
|
67
|
+
- `Esc`: Exit filter mode and clear query
|
|
68
|
+
- `Backspace`: Remove last filter character
|
|
69
|
+
|
|
70
|
+
History mode:
|
|
71
|
+
|
|
72
|
+
- `↑` / `↓`: Move through history entries (most recent at top)
|
|
73
|
+
- `Enter`: Jump to selected history path
|
|
74
|
+
- `Esc` or `h`: Exit history mode
|
|
75
|
+
- `PgUp` / `PgDn`, `Home` / `End`: Fast history navigation
|
|
76
|
+
- `/`: Enter history filter mode
|
|
77
|
+
|
|
78
|
+
History filter mode:
|
|
79
|
+
|
|
80
|
+
- Type text: Filter history entries in real time (case-insensitive)
|
|
81
|
+
- `↑` / `↓`: Move inside filtered history results
|
|
82
|
+
- `Enter`: Jump to selected history path
|
|
83
|
+
- `Esc`: Exit history filter mode and clear query
|
|
84
|
+
- `Backspace`: Remove last filter character
|
|
85
|
+
|
|
86
|
+
Open with menu (`o`):
|
|
87
|
+
|
|
88
|
+
- `↑` / `↓`: Move between app options
|
|
89
|
+
- `Enter`: Launch selected app
|
|
90
|
+
- `Esc`: Cancel
|
|
91
|
+
|
|
92
|
+
The menu only shows apps available in your system (`PATH`) and context:
|
|
93
|
+
|
|
94
|
+
- GUI options (when GUI is available): `code`, system `open`/`xdg-open`
|
|
95
|
+
- Terminal options: `antigravity`, `opencode`, `nvim`, `nano`, `less`, `bat` (as applicable)
|
|
96
|
+
|
|
97
|
+
## Opener Configuration
|
|
98
|
+
|
|
99
|
+
`cd-browser` reads opener settings from:
|
|
100
|
+
|
|
101
|
+
- macOS/Linux: `~/.config/cd-browser/config.toml`
|
|
102
|
+
- Windows: `%APPDATA%/cd-browser/config.toml`
|
|
103
|
+
|
|
104
|
+
If the file does not exist, `cd-browser` creates it automatically with defaults.
|
|
105
|
+
|
|
106
|
+
Example:
|
|
107
|
+
|
|
108
|
+
```toml
|
|
109
|
+
[open_with]
|
|
110
|
+
files = ["code", "open", "antigravity", "nvim", "nano", "less", "bat"]
|
|
111
|
+
directories = ["code", "opencode", "antigravity", "nvim"]
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
You can reorder or remove entries to customize app priority.
|
|
115
|
+
|
|
116
|
+
## Quick Demo
|
|
117
|
+
|
|
118
|
+
Run:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
cd_
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Browse directories using the arrow keys and press **Enter** to jump directly to the selected folder.
|
|
125
|
+
|
|
126
|
+
## Documentation
|
|
127
|
+
|
|
128
|
+
See the documentation index:
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
docs/index.md
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Installation
|
|
135
|
+
|
|
136
|
+
Install the project in user mode:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
pip install cd-browser
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**Important**: After installation, run this to set up the `cd_` command:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
cd_browser_post_install
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
This interactive script will guide you through enabling `cd_` in your shell.
|
|
149
|
+
|
|
150
|
+
## Shell Integration For `cd_`
|
|
151
|
+
|
|
152
|
+
Because a Python subprocess cannot directly change the parent shell directory, use a shell wrapper that captures the final path printed by `cd_browser`.
|
|
153
|
+
|
|
154
|
+
Bash or Zsh example:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
cd_() {
|
|
158
|
+
local target
|
|
159
|
+
target="$(cd_browser)" || return
|
|
160
|
+
|
|
161
|
+
if [ -n "$target" ] && [ -d "$target" ]; then
|
|
162
|
+
cd "$target"
|
|
163
|
+
fi
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
After adding the function to your shell profile, reload it:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
source ~/.bashrc
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Or:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
source ~/.zshrc
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Then use:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
cd_
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
- Inside `cd_browser`, press `.` to toggle hidden entries.
|
|
186
|
+
|
|
187
|
+
## Platform Compatibility
|
|
188
|
+
|
|
189
|
+
Practical conclusion:
|
|
190
|
+
|
|
191
|
+
- Best current support: **macOS and Linux**
|
|
192
|
+
- **Windows is partially supported**, but still needs real-world validation in Windows terminal environments before being considered fully supported
|
|
193
|
+
|
|
194
|
+
## Uninstall
|
|
195
|
+
|
|
196
|
+
If the project was installed with `pip`, remove it with:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
pip uninstall cd-browser
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**Important**: After uninstalling, remove the `cd_()` function from your shell profile (~/.bashrc or ~/.zshrc) to clean up completely.
|
|
203
|
+
|
|
204
|
+
## Developer Setup
|
|
205
|
+
|
|
206
|
+
Recommended setup:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
make dev
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Run the application in development mode:
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
python -m app.main
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Useful development commands:
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
make fix
|
|
222
|
+
make quality
|
|
223
|
+
make run
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Template Origin
|
|
227
|
+
|
|
228
|
+
This project was created from the Python AI Dev Template.
|
|
229
|
+
|
|
230
|
+
The original template documentation has been preserved in `README_TEMPLATE.md` so the project-specific README can focus on `cd-browser` usage and development.
|
|
231
|
+
|
|
232
|
+
For the original template setup, conventions, and generic workflow notes, see `README_TEMPLATE.md`.
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
|
|
5
|
+
from app.history import SessionHistory
|
|
5
6
|
from app.navigator import Navigator
|
|
6
7
|
from app.ui import TerminalUI
|
|
7
8
|
|
|
@@ -10,6 +11,6 @@ def run_cli(start_path: Path | None = None, ui: TerminalUI | None = None) -> Pat
|
|
|
10
11
|
"""Run the interactive directory browser and return the selected path."""
|
|
11
12
|
|
|
12
13
|
initial_path = (start_path or Path.cwd()).expanduser().resolve()
|
|
13
|
-
navigator = Navigator(initial_path)
|
|
14
|
+
navigator = Navigator(initial_path, history=SessionHistory.persistent())
|
|
14
15
|
terminal_ui = ui or TerminalUI()
|
|
15
16
|
return terminal_ui.run(navigator)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class DevSnapshot:
|
|
11
|
+
"""Development context for a path."""
|
|
12
|
+
|
|
13
|
+
repo_root: Path | None
|
|
14
|
+
git_branch: str | None
|
|
15
|
+
git_dirty: bool
|
|
16
|
+
last_commit: str | None
|
|
17
|
+
stack: tuple[str, ...]
|
|
18
|
+
commands: tuple[str, ...]
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def header_summary(self) -> str:
|
|
22
|
+
git_part = "git:-"
|
|
23
|
+
if self.git_branch:
|
|
24
|
+
dirty_marker = "*" if self.git_dirty else ""
|
|
25
|
+
git_part = f"git:{self.git_branch}{dirty_marker}"
|
|
26
|
+
|
|
27
|
+
stack_part = ",".join(self.stack[:2]) if self.stack else "-"
|
|
28
|
+
return f"{git_part} stack:{stack_part}"
|
|
29
|
+
|
|
30
|
+
def detail_lines(self, current_path: Path) -> list[str]:
|
|
31
|
+
lines = [f"Current path: {current_path}"]
|
|
32
|
+
if self.repo_root is None:
|
|
33
|
+
lines.append("Git: not a repository")
|
|
34
|
+
else:
|
|
35
|
+
dirty_text = "dirty" if self.git_dirty else "clean"
|
|
36
|
+
lines.append(f"Git root: {self.repo_root}")
|
|
37
|
+
lines.append(f"Git branch: {self.git_branch or '-'} ({dirty_text})")
|
|
38
|
+
if self.last_commit:
|
|
39
|
+
lines.append(f"Last commit: {self.last_commit}")
|
|
40
|
+
|
|
41
|
+
stack_text = ", ".join(self.stack) if self.stack else "-"
|
|
42
|
+
lines.append(f"Detected stack: {stack_text}")
|
|
43
|
+
|
|
44
|
+
if self.commands:
|
|
45
|
+
lines.append("Suggested commands:")
|
|
46
|
+
for command in self.commands:
|
|
47
|
+
lines.append(f" - {command}")
|
|
48
|
+
|
|
49
|
+
return lines
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class DevInfoCache:
|
|
53
|
+
"""Simple TTL cache for development snapshots."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, ttl_seconds: float = 3.0) -> None:
|
|
56
|
+
self._ttl_seconds = ttl_seconds
|
|
57
|
+
self._cached_path: Path | None = None
|
|
58
|
+
self._cached_at: float = 0.0
|
|
59
|
+
self._cached_snapshot: DevSnapshot | None = None
|
|
60
|
+
|
|
61
|
+
def get(self, path: Path) -> DevSnapshot:
|
|
62
|
+
resolved_path = path.expanduser().resolve()
|
|
63
|
+
now = time.monotonic()
|
|
64
|
+
if (
|
|
65
|
+
self._cached_snapshot is not None
|
|
66
|
+
and self._cached_path == resolved_path
|
|
67
|
+
and (now - self._cached_at) <= self._ttl_seconds
|
|
68
|
+
):
|
|
69
|
+
return self._cached_snapshot
|
|
70
|
+
|
|
71
|
+
snapshot = collect_dev_snapshot(resolved_path)
|
|
72
|
+
self._cached_path = resolved_path
|
|
73
|
+
self._cached_snapshot = snapshot
|
|
74
|
+
self._cached_at = now
|
|
75
|
+
return snapshot
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def collect_dev_snapshot(path: Path) -> DevSnapshot:
|
|
79
|
+
"""Collect git and stack metadata for a path."""
|
|
80
|
+
|
|
81
|
+
repo_root = _git_repo_root(path)
|
|
82
|
+
git_branch: str | None = None
|
|
83
|
+
git_dirty = False
|
|
84
|
+
last_commit: str | None = None
|
|
85
|
+
|
|
86
|
+
if repo_root is not None:
|
|
87
|
+
git_branch = _run_command(
|
|
88
|
+
["git", "-C", str(repo_root), "rev-parse", "--abbrev-ref", "HEAD"]
|
|
89
|
+
)
|
|
90
|
+
status_output = _run_command(
|
|
91
|
+
["git", "-C", str(repo_root), "status", "--porcelain"]
|
|
92
|
+
)
|
|
93
|
+
git_dirty = bool(status_output)
|
|
94
|
+
last_commit = _run_command(
|
|
95
|
+
["git", "-C", str(repo_root), "log", "-1", "--pretty=format:%h %s"]
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
stack = _detect_stack(path)
|
|
99
|
+
return DevSnapshot(
|
|
100
|
+
repo_root=repo_root,
|
|
101
|
+
git_branch=git_branch,
|
|
102
|
+
git_dirty=git_dirty,
|
|
103
|
+
last_commit=last_commit,
|
|
104
|
+
stack=stack,
|
|
105
|
+
commands=_suggested_commands(stack, repo_root is not None),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _git_repo_root(path: Path) -> Path | None:
|
|
110
|
+
result = _run_command(["git", "-C", str(path), "rev-parse", "--show-toplevel"])
|
|
111
|
+
if not result:
|
|
112
|
+
return None
|
|
113
|
+
return Path(result).expanduser().resolve()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _detect_stack(path: Path) -> tuple[str, ...]:
|
|
117
|
+
markers = (
|
|
118
|
+
("pyproject.toml", "python"),
|
|
119
|
+
("requirements.txt", "python"),
|
|
120
|
+
("package.json", "node"),
|
|
121
|
+
("go.mod", "go"),
|
|
122
|
+
("Cargo.toml", "rust"),
|
|
123
|
+
)
|
|
124
|
+
found: list[str] = []
|
|
125
|
+
for marker, stack_name in markers:
|
|
126
|
+
if (path / marker).exists() and stack_name not in found:
|
|
127
|
+
found.append(stack_name)
|
|
128
|
+
|
|
129
|
+
return tuple(found)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _suggested_commands(stack: tuple[str, ...], has_git: bool) -> tuple[str, ...]:
|
|
133
|
+
commands: list[str] = []
|
|
134
|
+
if has_git:
|
|
135
|
+
commands.append("git status -sb")
|
|
136
|
+
|
|
137
|
+
if "python" in stack:
|
|
138
|
+
commands.extend(["pytest -q", "ruff check src tests"])
|
|
139
|
+
if "node" in stack:
|
|
140
|
+
commands.extend(["npm test", "npm run lint"])
|
|
141
|
+
if "go" in stack:
|
|
142
|
+
commands.append("go test ./...")
|
|
143
|
+
if "rust" in stack:
|
|
144
|
+
commands.append("cargo test")
|
|
145
|
+
|
|
146
|
+
return tuple(commands)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _run_command(command: list[str]) -> str | None:
|
|
150
|
+
try:
|
|
151
|
+
result = subprocess.run(
|
|
152
|
+
command,
|
|
153
|
+
check=False,
|
|
154
|
+
capture_output=True,
|
|
155
|
+
text=True,
|
|
156
|
+
timeout=0.35,
|
|
157
|
+
)
|
|
158
|
+
except (OSError, subprocess.SubprocessError):
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
if result.returncode != 0:
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
output = result.stdout.strip()
|
|
165
|
+
if not output:
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
return output
|
|
@@ -13,11 +13,37 @@ class DirectoryEntry:
|
|
|
13
13
|
has_children: bool
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class FileSystemEntry:
|
|
18
|
+
"""Filesystem entry metadata for tree rendering."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
path: Path
|
|
22
|
+
is_directory: bool
|
|
23
|
+
has_children: bool
|
|
24
|
+
|
|
25
|
+
|
|
16
26
|
def list_directories(path: Path, show_hidden: bool = False) -> list[DirectoryEntry]:
|
|
17
27
|
"""Return direct child directories sorted by name."""
|
|
18
28
|
|
|
29
|
+
entries = list_entries(path, show_hidden=show_hidden, include_files=False)
|
|
30
|
+
return [
|
|
31
|
+
DirectoryEntry(
|
|
32
|
+
name=entry.name,
|
|
33
|
+
path=entry.path,
|
|
34
|
+
has_children=entry.has_children,
|
|
35
|
+
)
|
|
36
|
+
for entry in entries
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def list_entries(
|
|
41
|
+
path: Path, show_hidden: bool = False, include_files: bool = False
|
|
42
|
+
) -> list[FileSystemEntry]:
|
|
43
|
+
"""Return direct child entries sorted by type and name."""
|
|
44
|
+
|
|
19
45
|
directory_path = path.expanduser().resolve()
|
|
20
|
-
entries: list[
|
|
46
|
+
entries: list[FileSystemEntry] = []
|
|
21
47
|
|
|
22
48
|
try:
|
|
23
49
|
children = list(directory_path.iterdir())
|
|
@@ -30,20 +56,28 @@ def list_directories(path: Path, show_hidden: bool = False) -> list[DirectoryEnt
|
|
|
30
56
|
except OSError:
|
|
31
57
|
continue
|
|
32
58
|
|
|
33
|
-
if not is_directory:
|
|
59
|
+
if not is_directory and not include_files:
|
|
34
60
|
continue
|
|
35
61
|
if not show_hidden and child.name.startswith("."):
|
|
36
62
|
continue
|
|
37
63
|
|
|
38
64
|
entries.append(
|
|
39
|
-
|
|
65
|
+
FileSystemEntry(
|
|
40
66
|
name=child.name,
|
|
41
67
|
path=child,
|
|
42
|
-
|
|
68
|
+
is_directory=is_directory,
|
|
69
|
+
has_children=(
|
|
70
|
+
has_subdirectories(child, show_hidden=show_hidden)
|
|
71
|
+
if is_directory
|
|
72
|
+
else False
|
|
73
|
+
),
|
|
43
74
|
)
|
|
44
75
|
)
|
|
45
76
|
|
|
46
|
-
return sorted(
|
|
77
|
+
return sorted(
|
|
78
|
+
entries,
|
|
79
|
+
key=lambda entry: (not entry.is_directory, entry.name.casefold()),
|
|
80
|
+
)
|
|
47
81
|
|
|
48
82
|
|
|
49
83
|
def has_subdirectories(path: Path, show_hidden: bool = False) -> bool:
|