cd-browser 0.2.6__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 (36) hide show
  1. {cd_browser-0.2.6 → cd_browser-0.3.0}/PKG-INFO +36 -4
  2. {cd_browser-0.2.6 → cd_browser-0.3.0}/README.md +35 -3
  3. {cd_browser-0.2.6 → cd_browser-0.3.0}/pyproject.toml +1 -1
  4. {cd_browser-0.2.6 → 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.3.0/src/app/history.py +146 -0
  7. {cd_browser-0.2.6 → cd_browser-0.3.0}/src/app/opener.py +132 -10
  8. {cd_browser-0.2.6 → cd_browser-0.3.0}/src/app/ui.py +442 -40
  9. {cd_browser-0.2.6 → cd_browser-0.3.0}/src/cd_browser.egg-info/PKG-INFO +36 -4
  10. {cd_browser-0.2.6 → cd_browser-0.3.0}/src/cd_browser.egg-info/SOURCES.txt +1 -0
  11. {cd_browser-0.2.6 → cd_browser-0.3.0}/tests/test_history.py +46 -1
  12. {cd_browser-0.2.6 → cd_browser-0.3.0}/tests/test_opener.py +107 -0
  13. {cd_browser-0.2.6 → cd_browser-0.3.0}/tests/test_ui.py +132 -2
  14. cd_browser-0.2.6/src/app/history.py +0 -68
  15. {cd_browser-0.2.6 → cd_browser-0.3.0}/LICENSE +0 -0
  16. {cd_browser-0.2.6 → cd_browser-0.3.0}/setup.cfg +0 -0
  17. {cd_browser-0.2.6 → cd_browser-0.3.0}/src/app/__init__.py +0 -0
  18. {cd_browser-0.2.6 → cd_browser-0.3.0}/src/app/config.py +0 -0
  19. {cd_browser-0.2.6 → cd_browser-0.3.0}/src/app/domain/__init__.py +0 -0
  20. {cd_browser-0.2.6 → cd_browser-0.3.0}/src/app/filesystem.py +0 -0
  21. {cd_browser-0.2.6 → cd_browser-0.3.0}/src/app/infrastructure/__init__.py +0 -0
  22. {cd_browser-0.2.6 → cd_browser-0.3.0}/src/app/logging_config.py +0 -0
  23. {cd_browser-0.2.6 → cd_browser-0.3.0}/src/app/main.py +0 -0
  24. {cd_browser-0.2.6 → cd_browser-0.3.0}/src/app/navigator.py +0 -0
  25. {cd_browser-0.2.6 → cd_browser-0.3.0}/src/app/services/__init__.py +0 -0
  26. {cd_browser-0.2.6 → cd_browser-0.3.0}/src/app/utils/__init__.py +0 -0
  27. {cd_browser-0.2.6 → cd_browser-0.3.0}/src/cd_browser.egg-info/dependency_links.txt +0 -0
  28. {cd_browser-0.2.6 → cd_browser-0.3.0}/src/cd_browser.egg-info/entry_points.txt +0 -0
  29. {cd_browser-0.2.6 → cd_browser-0.3.0}/src/cd_browser.egg-info/requires.txt +0 -0
  30. {cd_browser-0.2.6 → cd_browser-0.3.0}/src/cd_browser.egg-info/top_level.txt +0 -0
  31. {cd_browser-0.2.6 → cd_browser-0.3.0}/src/cd_browser_post_install.py +0 -0
  32. {cd_browser-0.2.6 → cd_browser-0.3.0}/tests/test_cli.py +0 -0
  33. {cd_browser-0.2.6 → cd_browser-0.3.0}/tests/test_filesystem.py +0 -0
  34. {cd_browser-0.2.6 → cd_browser-0.3.0}/tests/test_navigator.py +0 -0
  35. {cd_browser-0.2.6 → cd_browser-0.3.0}/tests/test_post_install.py +0 -0
  36. {cd_browser-0.2.6 → 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.6
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,15 @@ 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)
53
+ - 📜 Persistent history between sessions (`h` history mode, `b` back, `f` forward)
54
54
  - 🔎 Incremental filter mode (`/`, type to filter, `Backspace`, `Esc`)
55
+ - 🧭 History filter mode (`/` inside history) with most-recent-first ordering
55
56
  - 📄 Toggle file visibility (`a`) to switch between directories-only and mixed view
56
57
  - ⚡ Fast jumps in large lists (`PgUp`/`PgDn`, `Home`/`End`, `g`/`G` in normal mode)
57
58
  - 🚀 Contextual `open with` menu (`o`) with app detection based on your environment
58
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`)
59
62
  - 👀 Toggle hidden entries with `.`
60
63
  - 🖥 Native terminal interface
61
64
  - 🔁 Works with Bash, Zsh and other shells
@@ -76,6 +79,7 @@ Normal mode:
76
79
  - `a`: Toggle file visibility
77
80
  - `/`: Enter filter mode
78
81
  - `o`: Open selected entry with an app
82
+ - `i`: Toggle development info panel
79
83
  - `PgUp` / `PgDn`: Page jump
80
84
  - `Home` / `End`: Jump to first/last entry
81
85
  - `g` / `G`: Jump to first/last entry
@@ -92,10 +96,19 @@ Filter mode:
92
96
 
93
97
  History mode:
94
98
 
95
- - `↑` / `↓`: Move through history entries
99
+ - `↑` / `↓`: Move through history entries (most recent at top)
96
100
  - `Enter`: Jump to selected history path
97
101
  - `Esc` or `h`: Exit history mode
98
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
99
112
 
100
113
  Open with menu (`o`):
101
114
 
@@ -106,7 +119,26 @@ Open with menu (`o`):
106
119
  The menu only shows apps available in your system (`PATH`) and context:
107
120
 
108
121
  - GUI options (when GUI is available): `code`, system `open`/`xdg-open`
109
- - Terminal options: `antigravity`, `nvim`, `nano`, `less`, `bat` (as applicable)
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.
110
142
 
111
143
  ## Quick Demo
112
144
 
@@ -23,12 +23,15 @@ Working in the terminal often means:
23
23
 
24
24
  - ⌨️ Fully keyboard-driven workflow (`↑` / `↓` / `←` / `→`)
25
25
  - 🌲 Expand and collapse directories (`→` to expand/enter, `←` to collapse/back)
26
- - 📜 Navigation history inside the session (`h` history mode, `b` back, `f` forward)
26
+ - 📜 Persistent history between sessions (`h` history mode, `b` back, `f` forward)
27
27
  - 🔎 Incremental filter mode (`/`, type to filter, `Backspace`, `Esc`)
28
+ - 🧭 History filter mode (`/` inside history) with most-recent-first ordering
28
29
  - 📄 Toggle file visibility (`a`) to switch between directories-only and mixed view
29
30
  - ⚡ Fast jumps in large lists (`PgUp`/`PgDn`, `Home`/`End`, `g`/`G` in normal mode)
30
31
  - 🚀 Contextual `open with` menu (`o`) with app detection based on your environment
31
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`)
32
35
  - 👀 Toggle hidden entries with `.`
33
36
  - 🖥 Native terminal interface
34
37
  - 🔁 Works with Bash, Zsh and other shells
@@ -49,6 +52,7 @@ Normal mode:
49
52
  - `a`: Toggle file visibility
50
53
  - `/`: Enter filter mode
51
54
  - `o`: Open selected entry with an app
55
+ - `i`: Toggle development info panel
52
56
  - `PgUp` / `PgDn`: Page jump
53
57
  - `Home` / `End`: Jump to first/last entry
54
58
  - `g` / `G`: Jump to first/last entry
@@ -65,10 +69,19 @@ Filter mode:
65
69
 
66
70
  History mode:
67
71
 
68
- - `↑` / `↓`: Move through history entries
72
+ - `↑` / `↓`: Move through history entries (most recent at top)
69
73
  - `Enter`: Jump to selected history path
70
74
  - `Esc` or `h`: Exit history mode
71
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
72
85
 
73
86
  Open with menu (`o`):
74
87
 
@@ -79,7 +92,26 @@ Open with menu (`o`):
79
92
  The menu only shows apps available in your system (`PATH`) and context:
80
93
 
81
94
  - GUI options (when GUI is available): `code`, system `open`/`xdg-open`
82
- - Terminal options: `antigravity`, `nvim`, `nano`, `less`, `bat` (as applicable)
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.
83
115
 
84
116
  ## Quick Demo
85
117
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cd-browser"
7
- version = "0.2.6"
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
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+
8
+ def default_history_path() -> Path:
9
+ """Return the default on-disk history location."""
10
+
11
+ override = os.environ.get("CD_BROWSER_HISTORY_FILE")
12
+ if override:
13
+ return Path(override).expanduser()
14
+
15
+ if os.name == "nt":
16
+ base = Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming")))
17
+ else:
18
+ base = Path(os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local/state")))
19
+
20
+ return base / "cd-browser" / "history.txt"
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class SessionHistory:
25
+ """In-memory session history for visited directories."""
26
+
27
+ _entries: list[Path] = field(default_factory=list)
28
+ _index: int = -1
29
+ storage_path: Path | None = None
30
+ max_entries: int = 500
31
+
32
+ @classmethod
33
+ def persistent(
34
+ cls, storage_path: Path | None = None, max_entries: int = 500
35
+ ) -> SessionHistory:
36
+ """Create a history instance backed by an on-disk file."""
37
+
38
+ history = cls(
39
+ storage_path=storage_path or default_history_path(),
40
+ max_entries=max_entries,
41
+ )
42
+ history._load()
43
+ return history
44
+
45
+ def visit(self, path: Path) -> Path:
46
+ """Record a directory visit and discard forward history when needed."""
47
+
48
+ resolved_path = path.expanduser().resolve()
49
+
50
+ if self.current == resolved_path:
51
+ return resolved_path
52
+
53
+ if self._index < len(self._entries) - 1:
54
+ self._entries = self._entries[: self._index + 1]
55
+
56
+ self._entries.append(resolved_path)
57
+ self._trim_to_limit()
58
+ self._index = len(self._entries) - 1
59
+ self._persist()
60
+ return resolved_path
61
+
62
+ @property
63
+ def current(self) -> Path | None:
64
+ """Return the current history entry."""
65
+
66
+ if self._index == -1:
67
+ return None
68
+
69
+ return self._entries[self._index]
70
+
71
+ def back(self) -> Path | None:
72
+ """Move backward in history when possible."""
73
+
74
+ if self._index <= 0:
75
+ return None
76
+
77
+ self._index -= 1
78
+ return self._entries[self._index]
79
+
80
+ def forward(self) -> Path | None:
81
+ """Move forward in history when possible."""
82
+
83
+ if self._index == -1 or self._index >= len(self._entries) - 1:
84
+ return None
85
+
86
+ self._index += 1
87
+ return self._entries[self._index]
88
+
89
+ def select(self, index: int) -> Path:
90
+ """Jump to a previously visited directory by history index."""
91
+
92
+ if index < 0 or index >= len(self._entries):
93
+ raise IndexError("History index out of range")
94
+
95
+ self._index = index
96
+ return self._entries[self._index]
97
+
98
+ def entries(self) -> tuple[Path, ...]:
99
+ """Return the full history as an immutable sequence."""
100
+
101
+ return tuple(self._entries)
102
+
103
+ def _load(self) -> None:
104
+ if self.storage_path is None:
105
+ return
106
+
107
+ try:
108
+ lines = self.storage_path.read_text(encoding="utf-8").splitlines()
109
+ except FileNotFoundError:
110
+ return
111
+ except OSError:
112
+ return
113
+
114
+ loaded_entries: list[Path] = []
115
+ for line in lines:
116
+ raw = line.strip()
117
+ if not raw:
118
+ continue
119
+
120
+ resolved = Path(raw).expanduser().resolve()
121
+ if loaded_entries and loaded_entries[-1] == resolved:
122
+ continue
123
+
124
+ loaded_entries.append(resolved)
125
+
126
+ self._entries = loaded_entries
127
+ self._trim_to_limit()
128
+ self._index = len(self._entries) - 1
129
+
130
+ def _persist(self) -> None:
131
+ if self.storage_path is None:
132
+ return
133
+
134
+ try:
135
+ self.storage_path.parent.mkdir(parents=True, exist_ok=True)
136
+ contents = "".join(f"{entry}\n" for entry in self._entries)
137
+ self.storage_path.write_text(contents, encoding="utf-8")
138
+ except OSError:
139
+ return
140
+
141
+ def _trim_to_limit(self) -> None:
142
+ limit = max(1, self.max_entries)
143
+ if len(self._entries) <= limit:
144
+ return
145
+
146
+ self._entries = self._entries[-limit:]