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.
Files changed (39) hide show
  1. {cd_browser-0.2.5 → cd_browser-0.3.0}/PKG-INFO +96 -6
  2. cd_browser-0.3.0/README.md +232 -0
  3. {cd_browser-0.2.5 → cd_browser-0.3.0}/pyproject.toml +1 -1
  4. {cd_browser-0.2.5 → cd_browser-0.3.0}/src/app/cli.py +2 -1
  5. cd_browser-0.3.0/src/app/devinfo.py +168 -0
  6. {cd_browser-0.2.5 → cd_browser-0.3.0}/src/app/filesystem.py +39 -5
  7. cd_browser-0.3.0/src/app/history.py +146 -0
  8. {cd_browser-0.2.5 → cd_browser-0.3.0}/src/app/navigator.py +27 -8
  9. cd_browser-0.3.0/src/app/opener.py +311 -0
  10. cd_browser-0.3.0/src/app/ui.py +1158 -0
  11. {cd_browser-0.2.5 → cd_browser-0.3.0}/src/cd_browser.egg-info/PKG-INFO +96 -6
  12. {cd_browser-0.2.5 → cd_browser-0.3.0}/src/cd_browser.egg-info/SOURCES.txt +3 -0
  13. {cd_browser-0.2.5 → cd_browser-0.3.0}/tests/test_filesystem.py +29 -1
  14. {cd_browser-0.2.5 → cd_browser-0.3.0}/tests/test_history.py +46 -1
  15. {cd_browser-0.2.5 → cd_browser-0.3.0}/tests/test_navigator.py +18 -0
  16. cd_browser-0.3.0/tests/test_opener.py +254 -0
  17. cd_browser-0.3.0/tests/test_ui.py +485 -0
  18. cd_browser-0.2.5/README.md +0 -142
  19. cd_browser-0.2.5/src/app/history.py +0 -68
  20. cd_browser-0.2.5/src/app/ui.py +0 -344
  21. cd_browser-0.2.5/tests/test_ui.py +0 -236
  22. {cd_browser-0.2.5 → cd_browser-0.3.0}/LICENSE +0 -0
  23. {cd_browser-0.2.5 → cd_browser-0.3.0}/setup.cfg +0 -0
  24. {cd_browser-0.2.5 → cd_browser-0.3.0}/src/app/__init__.py +0 -0
  25. {cd_browser-0.2.5 → cd_browser-0.3.0}/src/app/config.py +0 -0
  26. {cd_browser-0.2.5 → cd_browser-0.3.0}/src/app/domain/__init__.py +0 -0
  27. {cd_browser-0.2.5 → cd_browser-0.3.0}/src/app/infrastructure/__init__.py +0 -0
  28. {cd_browser-0.2.5 → cd_browser-0.3.0}/src/app/logging_config.py +0 -0
  29. {cd_browser-0.2.5 → cd_browser-0.3.0}/src/app/main.py +0 -0
  30. {cd_browser-0.2.5 → cd_browser-0.3.0}/src/app/services/__init__.py +0 -0
  31. {cd_browser-0.2.5 → cd_browser-0.3.0}/src/app/utils/__init__.py +0 -0
  32. {cd_browser-0.2.5 → cd_browser-0.3.0}/src/cd_browser.egg-info/dependency_links.txt +0 -0
  33. {cd_browser-0.2.5 → cd_browser-0.3.0}/src/cd_browser.egg-info/entry_points.txt +0 -0
  34. {cd_browser-0.2.5 → cd_browser-0.3.0}/src/cd_browser.egg-info/requires.txt +0 -0
  35. {cd_browser-0.2.5 → cd_browser-0.3.0}/src/cd_browser.egg-info/top_level.txt +0 -0
  36. {cd_browser-0.2.5 → cd_browser-0.3.0}/src/cd_browser_post_install.py +0 -0
  37. {cd_browser-0.2.5 → cd_browser-0.3.0}/tests/test_cli.py +0 -0
  38. {cd_browser-0.2.5 → cd_browser-0.3.0}/tests/test_post_install.py +0 -0
  39. {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.2.5
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
- - 📜 Navigation history inside the session (`h` history mode, `b` back, `f` forward)
54
- - ⚡ Returns the selected path to the shell (`Enter` to confirm, `Esc` to cancel)
55
- - 🔎 Press `.` to toggle hidden directories
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
- - 🚀 Fast visual navigation of directory trees
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
- - Dentro de `cd_browser`, presiona `.` para alternar la visualización de carpetas ocultas.
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
+ ![cd-browser demo](https://gitea.sakydogalo.es/saky/cd-browser/raw/branch/main/docs/demo.gif)
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`.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cd-browser"
7
- version = "0.2.5"
7
+ version = "0.3.0"
8
8
  description = "A fast keyboard-driven directory navigator for the terminal."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -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[DirectoryEntry] = []
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
- DirectoryEntry(
65
+ FileSystemEntry(
40
66
  name=child.name,
41
67
  path=child,
42
- has_children=has_subdirectories(child, show_hidden=show_hidden),
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(entries, key=lambda entry: entry.name.casefold())
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: