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.
- {cd_browser-0.2.6 → cd_browser-0.3.0}/PKG-INFO +36 -4
- {cd_browser-0.2.6 → cd_browser-0.3.0}/README.md +35 -3
- {cd_browser-0.2.6 → cd_browser-0.3.0}/pyproject.toml +1 -1
- {cd_browser-0.2.6 → 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.3.0/src/app/history.py +146 -0
- {cd_browser-0.2.6 → cd_browser-0.3.0}/src/app/opener.py +132 -10
- {cd_browser-0.2.6 → cd_browser-0.3.0}/src/app/ui.py +442 -40
- {cd_browser-0.2.6 → cd_browser-0.3.0}/src/cd_browser.egg-info/PKG-INFO +36 -4
- {cd_browser-0.2.6 → cd_browser-0.3.0}/src/cd_browser.egg-info/SOURCES.txt +1 -0
- {cd_browser-0.2.6 → cd_browser-0.3.0}/tests/test_history.py +46 -1
- {cd_browser-0.2.6 → cd_browser-0.3.0}/tests/test_opener.py +107 -0
- {cd_browser-0.2.6 → cd_browser-0.3.0}/tests/test_ui.py +132 -2
- cd_browser-0.2.6/src/app/history.py +0 -68
- {cd_browser-0.2.6 → cd_browser-0.3.0}/LICENSE +0 -0
- {cd_browser-0.2.6 → cd_browser-0.3.0}/setup.cfg +0 -0
- {cd_browser-0.2.6 → cd_browser-0.3.0}/src/app/__init__.py +0 -0
- {cd_browser-0.2.6 → cd_browser-0.3.0}/src/app/config.py +0 -0
- {cd_browser-0.2.6 → cd_browser-0.3.0}/src/app/domain/__init__.py +0 -0
- {cd_browser-0.2.6 → cd_browser-0.3.0}/src/app/filesystem.py +0 -0
- {cd_browser-0.2.6 → cd_browser-0.3.0}/src/app/infrastructure/__init__.py +0 -0
- {cd_browser-0.2.6 → cd_browser-0.3.0}/src/app/logging_config.py +0 -0
- {cd_browser-0.2.6 → cd_browser-0.3.0}/src/app/main.py +0 -0
- {cd_browser-0.2.6 → cd_browser-0.3.0}/src/app/navigator.py +0 -0
- {cd_browser-0.2.6 → cd_browser-0.3.0}/src/app/services/__init__.py +0 -0
- {cd_browser-0.2.6 → cd_browser-0.3.0}/src/app/utils/__init__.py +0 -0
- {cd_browser-0.2.6 → cd_browser-0.3.0}/src/cd_browser.egg-info/dependency_links.txt +0 -0
- {cd_browser-0.2.6 → cd_browser-0.3.0}/src/cd_browser.egg-info/entry_points.txt +0 -0
- {cd_browser-0.2.6 → cd_browser-0.3.0}/src/cd_browser.egg-info/requires.txt +0 -0
- {cd_browser-0.2.6 → cd_browser-0.3.0}/src/cd_browser.egg-info/top_level.txt +0 -0
- {cd_browser-0.2.6 → cd_browser-0.3.0}/src/cd_browser_post_install.py +0 -0
- {cd_browser-0.2.6 → cd_browser-0.3.0}/tests/test_cli.py +0 -0
- {cd_browser-0.2.6 → cd_browser-0.3.0}/tests/test_filesystem.py +0 -0
- {cd_browser-0.2.6 → cd_browser-0.3.0}/tests/test_navigator.py +0 -0
- {cd_browser-0.2.6 → cd_browser-0.3.0}/tests/test_post_install.py +0 -0
- {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.
|
|
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
|
-
- 📜
|
|
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
|
-
- 📜
|
|
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
|
|
|
@@ -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:]
|