pcd-cli 0.1.0__py3-none-any.whl
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.
- pcd_cli/__init__.py +0 -0
- pcd_cli/cache.py +118 -0
- pcd_cli/catalog.py +157 -0
- pcd_cli/cli.py +341 -0
- pcd_cli/config.py +226 -0
- pcd_cli/filesystem.py +79 -0
- pcd_cli/history.py +95 -0
- pcd_cli/models.py +39 -0
- pcd_cli/navigation.py +80 -0
- pcd_cli/picker.py +140 -0
- pcd_cli/scanner.py +116 -0
- pcd_cli/search.py +108 -0
- pcd_cli/shell_integration.py +264 -0
- pcd_cli-0.1.0.dist-info/METADATA +105 -0
- pcd_cli-0.1.0.dist-info/RECORD +18 -0
- pcd_cli-0.1.0.dist-info/WHEEL +4 -0
- pcd_cli-0.1.0.dist-info/entry_points.txt +3 -0
- pcd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
pcd_cli/config.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import TYPE_CHECKING, TypeIs
|
|
4
|
+
|
|
5
|
+
import tomlkit
|
|
6
|
+
|
|
7
|
+
from pcd_cli.filesystem import atomic_write, canonical_path, file_lock
|
|
8
|
+
from pcd_cli.models import Project, ProjectSettings, ProjectSource
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
|
|
13
|
+
from tomlkit import TOMLDocument
|
|
14
|
+
|
|
15
|
+
ROOT_KEYS = frozenset({"roots", "scan", "projects"})
|
|
16
|
+
SCAN_KEYS = frozenset({"hidden", "follow_symlinks", "exclude"})
|
|
17
|
+
PROJECT_KEYS = frozenset({"name", "path"})
|
|
18
|
+
|
|
19
|
+
DEFAULT_EXCLUDED_NAMES = frozenset(
|
|
20
|
+
{
|
|
21
|
+
".cache",
|
|
22
|
+
".venv",
|
|
23
|
+
"__pycache__",
|
|
24
|
+
"build",
|
|
25
|
+
"dist",
|
|
26
|
+
"node_modules",
|
|
27
|
+
"target",
|
|
28
|
+
"vendor",
|
|
29
|
+
"venv",
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class InvalidConfigError(RuntimeError):
|
|
35
|
+
"""The configuration exists but cannot be interpreted safely."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class Config:
|
|
40
|
+
path: Path
|
|
41
|
+
|
|
42
|
+
def load(self) -> ProjectSettings:
|
|
43
|
+
return _decode_settings(self._read().unwrap())
|
|
44
|
+
|
|
45
|
+
def add_root(self, root: Path) -> bool:
|
|
46
|
+
display_path = root.expanduser()
|
|
47
|
+
target = canonical_path(display_path)
|
|
48
|
+
|
|
49
|
+
with file_lock(self.path):
|
|
50
|
+
document = self._read()
|
|
51
|
+
settings = _decode_settings(document.unwrap())
|
|
52
|
+
if any(canonical_path(item) == target for item in settings.roots):
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
document["roots"] = [str(item) for item in (*settings.roots, display_path)]
|
|
56
|
+
self._write(document)
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
def remove_root(self, root: Path) -> bool:
|
|
60
|
+
target = canonical_path(root)
|
|
61
|
+
|
|
62
|
+
with file_lock(self.path):
|
|
63
|
+
document = self._read()
|
|
64
|
+
settings = _decode_settings(document.unwrap())
|
|
65
|
+
roots = tuple(item for item in settings.roots if canonical_path(item) != target)
|
|
66
|
+
if len(roots) == len(settings.roots):
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
document["roots"] = [str(item) for item in roots]
|
|
70
|
+
self._write(document)
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
def add_project(self, project: Project) -> bool:
|
|
74
|
+
if project.source is not ProjectSource.MANUAL:
|
|
75
|
+
raise ValueError("Only manual projects belong in the config")
|
|
76
|
+
|
|
77
|
+
with file_lock(self.path):
|
|
78
|
+
document = self._read()
|
|
79
|
+
settings = _decode_settings(document.unwrap())
|
|
80
|
+
if any(item.path == project.path for item in settings.manual_projects):
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
document["projects"] = [
|
|
84
|
+
{"name": item.name, "path": str(item.display_path)}
|
|
85
|
+
for item in (*settings.manual_projects, project)
|
|
86
|
+
]
|
|
87
|
+
self._write(document)
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
def remove_project(self, path: Path) -> bool:
|
|
91
|
+
target = canonical_path(path)
|
|
92
|
+
|
|
93
|
+
with file_lock(self.path):
|
|
94
|
+
document = self._read()
|
|
95
|
+
settings = _decode_settings(document.unwrap())
|
|
96
|
+
projects = tuple(item for item in settings.manual_projects if item.path != target)
|
|
97
|
+
if len(projects) == len(settings.manual_projects):
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
document["projects"] = [
|
|
101
|
+
{"name": item.name, "path": str(item.display_path)} for item in projects
|
|
102
|
+
]
|
|
103
|
+
self._write(document)
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
def _read(self) -> TOMLDocument:
|
|
107
|
+
if not self.path.is_file():
|
|
108
|
+
return _default_document()
|
|
109
|
+
|
|
110
|
+
text = self.path.read_text(encoding="utf-8")
|
|
111
|
+
try:
|
|
112
|
+
return tomlkit.parse(text)
|
|
113
|
+
except tomlkit.exceptions.ParseError as exc:
|
|
114
|
+
raise InvalidConfigError(f"Invalid TOML in {self.path}") from exc
|
|
115
|
+
|
|
116
|
+
def _write(self, document: TOMLDocument) -> None:
|
|
117
|
+
with atomic_write(self.path) as stream:
|
|
118
|
+
stream.write(tomlkit.dumps(document))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _default_document() -> TOMLDocument:
|
|
122
|
+
document = tomlkit.document()
|
|
123
|
+
document.add(tomlkit.comment("pcd-cli configuration"))
|
|
124
|
+
document["roots"] = []
|
|
125
|
+
|
|
126
|
+
scan = tomlkit.table()
|
|
127
|
+
scan["hidden"] = True
|
|
128
|
+
scan["follow_symlinks"] = False
|
|
129
|
+
scan["exclude"] = sorted(DEFAULT_EXCLUDED_NAMES)
|
|
130
|
+
document["scan"] = scan
|
|
131
|
+
document["projects"] = tomlkit.aot()
|
|
132
|
+
return document
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _decode_settings(value: object) -> ProjectSettings:
|
|
136
|
+
config = _table(value, "Config root must be a table")
|
|
137
|
+
_validate_keys(config, ROOT_KEYS, "config")
|
|
138
|
+
roots = tuple(
|
|
139
|
+
_absolute_path(item, "root path") for item in _array(config.get("roots", []), "roots")
|
|
140
|
+
)
|
|
141
|
+
manual_projects = tuple(
|
|
142
|
+
_decode_manual_project(item) for item in _array(config.get("projects", []), "projects")
|
|
143
|
+
)
|
|
144
|
+
scan = _table(config.get("scan", {}), "scan must be a table")
|
|
145
|
+
_validate_keys(scan, SCAN_KEYS, "scan")
|
|
146
|
+
include_hidden = _boolean(scan.get("hidden", True), "scan.hidden")
|
|
147
|
+
follow_symlinks = _boolean(scan.get("follow_symlinks", False), "scan.follow_symlinks")
|
|
148
|
+
excluded_names = frozenset(
|
|
149
|
+
_text(item, "scan.exclude item")
|
|
150
|
+
for item in _array(
|
|
151
|
+
scan.get("exclude", list(DEFAULT_EXCLUDED_NAMES)),
|
|
152
|
+
"scan.exclude",
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if len({canonical_path(root) for root in roots}) != len(roots):
|
|
157
|
+
raise InvalidConfigError("Config contains duplicate roots")
|
|
158
|
+
if len({project.path for project in manual_projects}) != len(manual_projects):
|
|
159
|
+
raise InvalidConfigError("Config contains duplicate manual projects")
|
|
160
|
+
|
|
161
|
+
return ProjectSettings(
|
|
162
|
+
roots=roots,
|
|
163
|
+
manual_projects=manual_projects,
|
|
164
|
+
include_hidden=include_hidden,
|
|
165
|
+
follow_symlinks=follow_symlinks,
|
|
166
|
+
excluded_names=excluded_names,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _decode_manual_project(value: object) -> Project:
|
|
171
|
+
project = _table(value, "project entries must be tables")
|
|
172
|
+
_validate_keys(project, PROJECT_KEYS, "project")
|
|
173
|
+
name = _text(project.get("name"), "project name")
|
|
174
|
+
display_path = _absolute_path(project.get("path"), "project path")
|
|
175
|
+
return Project(
|
|
176
|
+
name=name,
|
|
177
|
+
path=canonical_path(display_path),
|
|
178
|
+
display_path=display_path,
|
|
179
|
+
source=ProjectSource.MANUAL,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _table(value: object, message: str) -> Mapping[str, object]:
|
|
184
|
+
if not _is_table(value):
|
|
185
|
+
raise InvalidConfigError(message)
|
|
186
|
+
return value
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _is_table(value: object) -> TypeIs[Mapping[str, object]]:
|
|
190
|
+
return isinstance(value, dict) and all(isinstance(key, str) for key in value)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _validate_keys(table: Mapping[str, object], allowed: frozenset[str], name: str) -> None:
|
|
194
|
+
unknown = sorted(set(table) - allowed)
|
|
195
|
+
if unknown:
|
|
196
|
+
joined = ", ".join(unknown)
|
|
197
|
+
raise InvalidConfigError(f"Unknown {name} key(s): {joined}")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _array(value: object, name: str) -> list[object]:
|
|
201
|
+
if not isinstance(value, list):
|
|
202
|
+
raise InvalidConfigError(f"{name} must be an array")
|
|
203
|
+
return value
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _text(value: object, name: str) -> str:
|
|
207
|
+
if not isinstance(value, str) or not value.strip():
|
|
208
|
+
raise InvalidConfigError(f"{name} must be a non-empty string")
|
|
209
|
+
return value
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _absolute_path(value: object, name: str) -> Path:
|
|
213
|
+
try:
|
|
214
|
+
path = Path(_text(value, name)).expanduser()
|
|
215
|
+
except RuntimeError as exc:
|
|
216
|
+
raise InvalidConfigError(f"{name} contains an unknown home directory") from exc
|
|
217
|
+
|
|
218
|
+
if not path.is_absolute():
|
|
219
|
+
raise InvalidConfigError(f"{name} must be absolute or start with ~")
|
|
220
|
+
return path
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _boolean(value: object, name: str) -> bool:
|
|
224
|
+
if not isinstance(value, bool):
|
|
225
|
+
raise InvalidConfigError(f"{name} must be a boolean")
|
|
226
|
+
return value
|
pcd_cli/filesystem.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import fcntl
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Self, TextIO, TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from platformdirs import PlatformDirs
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Generator
|
|
13
|
+
|
|
14
|
+
APP_NAME = "pcd-cli"
|
|
15
|
+
CONFIG_FILENAME = "config.toml"
|
|
16
|
+
CACHE_FILENAME = "projects.jsonl"
|
|
17
|
+
HISTORY_FILENAME = "history.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class ApplicationPaths:
|
|
22
|
+
config: Path
|
|
23
|
+
cache: Path
|
|
24
|
+
history: Path
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def resolve(cls) -> Self:
|
|
28
|
+
directories = PlatformDirs(APP_NAME, appauthor=False)
|
|
29
|
+
return cls(
|
|
30
|
+
config=directories.user_config_path / CONFIG_FILENAME,
|
|
31
|
+
cache=directories.user_cache_path / CACHE_FILENAME,
|
|
32
|
+
history=directories.user_state_path / HISTORY_FILENAME,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def canonical_path(path: Path) -> Path:
|
|
37
|
+
return path.expanduser().resolve(strict=False)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def format_path(path: Path) -> str:
|
|
41
|
+
home = Path.home()
|
|
42
|
+
if not path.is_relative_to(home):
|
|
43
|
+
return str(path)
|
|
44
|
+
|
|
45
|
+
relative = path.relative_to(home)
|
|
46
|
+
return "~" if not relative.parts else f"~/{relative}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@contextmanager
|
|
50
|
+
def file_lock(path: Path) -> Generator[None]:
|
|
51
|
+
"""Prevent concurrent writers from overwriting each other's changes."""
|
|
52
|
+
lock_path = path.with_name(f".{path.name}.lock")
|
|
53
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
|
|
55
|
+
with lock_path.open("a+", encoding="utf-8") as stream:
|
|
56
|
+
fcntl.flock(stream.fileno(), fcntl.LOCK_EX)
|
|
57
|
+
try:
|
|
58
|
+
yield
|
|
59
|
+
finally:
|
|
60
|
+
fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@contextmanager
|
|
64
|
+
def atomic_write(path: Path) -> Generator[TextIO]:
|
|
65
|
+
"""Write through a temporary file so readers never observe partial data."""
|
|
66
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
68
|
+
temporary_path = Path(temporary_name)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
72
|
+
yield stream
|
|
73
|
+
stream.flush()
|
|
74
|
+
os.fsync(stream.fileno())
|
|
75
|
+
|
|
76
|
+
os.replace(temporary_path, path)
|
|
77
|
+
except BaseException:
|
|
78
|
+
temporary_path.unlink(missing_ok=True)
|
|
79
|
+
raise
|
pcd_cli/history.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TypedDict, TypeIs
|
|
6
|
+
|
|
7
|
+
from pcd_cli.filesystem import atomic_write, file_lock
|
|
8
|
+
from pcd_cli.models import ProjectUsage
|
|
9
|
+
|
|
10
|
+
DEFAULT_HISTORY_LIMIT = 2048
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UsageHistoryEntry(TypedDict):
|
|
14
|
+
used_at: int | float
|
|
15
|
+
count: int
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class UsageHistory:
|
|
20
|
+
"""Bounded usage history used as a tie-breaker during search."""
|
|
21
|
+
|
|
22
|
+
path: Path
|
|
23
|
+
limit: int = DEFAULT_HISTORY_LIMIT
|
|
24
|
+
|
|
25
|
+
def load(self) -> dict[Path, ProjectUsage]:
|
|
26
|
+
if not self.path.is_file():
|
|
27
|
+
return {}
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
with self.path.open(encoding="utf-8") as stream:
|
|
31
|
+
raw: object = json.load(stream)
|
|
32
|
+
except OSError, UnicodeError, json.JSONDecodeError:
|
|
33
|
+
return {}
|
|
34
|
+
|
|
35
|
+
return _decode_usage(raw)
|
|
36
|
+
|
|
37
|
+
def record(self, path: Path) -> None:
|
|
38
|
+
with file_lock(self.path):
|
|
39
|
+
usage = self.load()
|
|
40
|
+
previous = usage.get(path)
|
|
41
|
+
count = 1 if previous is None else previous.count + 1
|
|
42
|
+
usage[path] = ProjectUsage(used_at=time.time(), count=count)
|
|
43
|
+
retained = _retain_recent(usage, self.limit)
|
|
44
|
+
|
|
45
|
+
payload: dict[str, UsageHistoryEntry] = {
|
|
46
|
+
str(project_path): {"used_at": item.used_at, "count": item.count}
|
|
47
|
+
for project_path, item in retained.items()
|
|
48
|
+
}
|
|
49
|
+
with atomic_write(self.path) as stream:
|
|
50
|
+
json.dump(payload, stream, ensure_ascii=False, separators=(",", ":"))
|
|
51
|
+
stream.write("\n")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _decode_usage(value: object) -> dict[Path, ProjectUsage]:
|
|
55
|
+
if not _is_usage_mapping(value):
|
|
56
|
+
return {}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
Path(path): ProjectUsage(float(entry["used_at"]), entry["count"])
|
|
60
|
+
for path, entry in value.items()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_usage_mapping(value: object) -> TypeIs[dict[str, UsageHistoryEntry]]:
|
|
65
|
+
return isinstance(value, dict) and all(
|
|
66
|
+
isinstance(path, str) and _is_usage_entry(entry) for path, entry in value.items()
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _is_usage_entry(value: object) -> TypeIs[UsageHistoryEntry]:
|
|
71
|
+
if not isinstance(value, dict):
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
used_at = value.get("used_at")
|
|
75
|
+
count = value.get("count")
|
|
76
|
+
valid_time = not isinstance(used_at, bool) and isinstance(used_at, int | float)
|
|
77
|
+
valid_count = not isinstance(count, bool) and isinstance(count, int) and count > 0
|
|
78
|
+
return valid_time and valid_count
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _retain_recent(
|
|
82
|
+
usage: dict[Path, ProjectUsage],
|
|
83
|
+
limit: int,
|
|
84
|
+
) -> dict[Path, ProjectUsage]:
|
|
85
|
+
if limit <= 0:
|
|
86
|
+
return {}
|
|
87
|
+
if len(usage) <= limit:
|
|
88
|
+
return usage
|
|
89
|
+
|
|
90
|
+
newest = sorted(
|
|
91
|
+
usage.items(),
|
|
92
|
+
key=lambda item: (item[1].used_at, item[1].count, str(item[0])),
|
|
93
|
+
reverse=True,
|
|
94
|
+
)[:limit]
|
|
95
|
+
return dict(newest)
|
pcd_cli/models.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from enum import IntEnum, StrEnum
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ProjectSource(StrEnum):
|
|
10
|
+
DISCOVERED = "discovered"
|
|
11
|
+
MANUAL = "manual"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ExitCode(IntEnum):
|
|
15
|
+
ERROR = 1
|
|
16
|
+
NOT_FOUND = 3
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class Project:
|
|
21
|
+
name: str
|
|
22
|
+
path: Path
|
|
23
|
+
display_path: Path
|
|
24
|
+
source: ProjectSource
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class ProjectSettings:
|
|
29
|
+
roots: tuple[Path, ...]
|
|
30
|
+
manual_projects: tuple[Project, ...]
|
|
31
|
+
include_hidden: bool
|
|
32
|
+
follow_symlinks: bool
|
|
33
|
+
excluded_names: frozenset[str]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class ProjectUsage:
|
|
38
|
+
used_at: float
|
|
39
|
+
count: int
|
pcd_cli/navigation.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from click.shell_completion import CompletionItem
|
|
5
|
+
|
|
6
|
+
from pcd_cli.catalog import ProjectCatalog
|
|
7
|
+
from pcd_cli.filesystem import format_path
|
|
8
|
+
from pcd_cli.models import ExitCode, ProjectSource
|
|
9
|
+
from pcd_cli.shell_integration import (
|
|
10
|
+
inactive_shell_message,
|
|
11
|
+
SHELL_CD_EXIT_CODE,
|
|
12
|
+
shell_integration_active,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
|
|
18
|
+
from pcd_cli.models import Project
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def navigate_to_project(
|
|
22
|
+
catalog: ProjectCatalog,
|
|
23
|
+
query: str | None = None,
|
|
24
|
+
exact_only: bool = False,
|
|
25
|
+
) -> None:
|
|
26
|
+
matches = catalog.projects() if query is None else catalog.search(query, exact_only=exact_only)
|
|
27
|
+
if not matches:
|
|
28
|
+
suffix = "" if query is None else f": {query}"
|
|
29
|
+
click.echo(f"Project not found{suffix}", err=True)
|
|
30
|
+
raise click.exceptions.Exit(ExitCode.NOT_FOUND)
|
|
31
|
+
|
|
32
|
+
selected = matches[0] if len(matches) == 1 else select_project(catalog, matches, query or "")
|
|
33
|
+
if selected is not None:
|
|
34
|
+
_open_project(catalog, selected)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def select_project(
|
|
38
|
+
catalog: ProjectCatalog,
|
|
39
|
+
projects: Sequence[Project],
|
|
40
|
+
query: str = "",
|
|
41
|
+
) -> Project | None:
|
|
42
|
+
# Import lazily so shell completion never initializes terminal UI machinery.
|
|
43
|
+
from pcd_cli.picker import ProjectPicker
|
|
44
|
+
|
|
45
|
+
return ProjectPicker(projects, catalog.history.load(), query).run()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def project_completions(value: str) -> list[CompletionItem]:
|
|
49
|
+
names: set[str] = set()
|
|
50
|
+
completions: list[CompletionItem] = []
|
|
51
|
+
|
|
52
|
+
for project in ProjectCatalog.create().completion_candidates(value):
|
|
53
|
+
if project.name in names:
|
|
54
|
+
continue
|
|
55
|
+
names.add(project.name)
|
|
56
|
+
completions.append(CompletionItem(project.name, help=format_path(project.display_path)))
|
|
57
|
+
|
|
58
|
+
return completions
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _open_project(catalog: ProjectCatalog, project: Project) -> None:
|
|
62
|
+
if project.path.is_dir():
|
|
63
|
+
catalog.record_usage(project)
|
|
64
|
+
elif project.source is ProjectSource.MANUAL:
|
|
65
|
+
click.echo(
|
|
66
|
+
f"Project path no longer exists: {format_path(project.display_path)}",
|
|
67
|
+
err=True,
|
|
68
|
+
)
|
|
69
|
+
raise click.exceptions.Exit(ExitCode.ERROR)
|
|
70
|
+
else:
|
|
71
|
+
catalog.refresh()
|
|
72
|
+
click.echo("Selected project no longer exists.", err=True)
|
|
73
|
+
raise click.exceptions.Exit(ExitCode.ERROR)
|
|
74
|
+
|
|
75
|
+
if shell_integration_active():
|
|
76
|
+
click.echo(project.path)
|
|
77
|
+
raise click.exceptions.Exit(SHELL_CD_EXIT_CODE)
|
|
78
|
+
|
|
79
|
+
click.echo(project.path)
|
|
80
|
+
click.echo(inactive_shell_message(), err=True)
|
pcd_cli/picker.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from prompt_toolkit import Application
|
|
6
|
+
from prompt_toolkit.buffer import Buffer
|
|
7
|
+
from prompt_toolkit.document import Document
|
|
8
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
9
|
+
from prompt_toolkit.layout import HSplit, Layout, Window
|
|
10
|
+
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
|
|
11
|
+
from prompt_toolkit.layout.processors import BeforeInput
|
|
12
|
+
from prompt_toolkit.output.defaults import create_output
|
|
13
|
+
|
|
14
|
+
from pcd_cli.filesystem import format_path
|
|
15
|
+
from pcd_cli.search import rank_matches, rank_recent
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Mapping, Sequence
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from prompt_toolkit.formatted_text import StyleAndTextTuples
|
|
22
|
+
from prompt_toolkit.input.base import Input
|
|
23
|
+
from prompt_toolkit.key_binding import KeyPressEvent
|
|
24
|
+
from prompt_toolkit.output import Output
|
|
25
|
+
|
|
26
|
+
from pcd_cli.models import Project, ProjectUsage
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
PAGE_SIZE = 12
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True)
|
|
33
|
+
class ProjectPicker:
|
|
34
|
+
"""Project-specific interactive picker built on prompt-toolkit primitives."""
|
|
35
|
+
|
|
36
|
+
projects: Sequence[Project]
|
|
37
|
+
usage: Mapping[Path, ProjectUsage]
|
|
38
|
+
query: str = ""
|
|
39
|
+
input_stream: Input | None = None
|
|
40
|
+
output: Output | None = None
|
|
41
|
+
|
|
42
|
+
matches: list[Project] = field(default_factory=list, init=False)
|
|
43
|
+
selected_index: int = field(default=0, init=False)
|
|
44
|
+
application: Application[Project | None] | None = field(default=None, init=False)
|
|
45
|
+
|
|
46
|
+
def run(self) -> Project | None:
|
|
47
|
+
self.matches = self._matching_projects(self.query)
|
|
48
|
+
buffer = Buffer(
|
|
49
|
+
document=Document(self.query, cursor_position=len(self.query)),
|
|
50
|
+
multiline=False,
|
|
51
|
+
on_text_changed=self._filter,
|
|
52
|
+
)
|
|
53
|
+
bindings = KeyBindings()
|
|
54
|
+
bindings.add("up")(self._move_up)
|
|
55
|
+
bindings.add("down")(self._move_down)
|
|
56
|
+
bindings.add("enter")(self._select)
|
|
57
|
+
bindings.add("escape")(self._cancel)
|
|
58
|
+
bindings.add("c-c")(self._cancel)
|
|
59
|
+
|
|
60
|
+
search = Window(
|
|
61
|
+
BufferControl(buffer=buffer, input_processors=[BeforeInput("Search: ")]),
|
|
62
|
+
height=1,
|
|
63
|
+
)
|
|
64
|
+
results = Window(content=FormattedTextControl(self._render), dont_extend_height=True)
|
|
65
|
+
output = self.output if self.output is not None else create_output(stdout=sys.stderr)
|
|
66
|
+
application: Application[Project | None] = Application(
|
|
67
|
+
layout=Layout(HSplit([search, results]), focused_element=search),
|
|
68
|
+
key_bindings=bindings,
|
|
69
|
+
full_screen=False,
|
|
70
|
+
erase_when_done=True,
|
|
71
|
+
input=self.input_stream,
|
|
72
|
+
output=output,
|
|
73
|
+
)
|
|
74
|
+
self.application = application
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
return application.run()
|
|
78
|
+
finally:
|
|
79
|
+
self.application = None
|
|
80
|
+
|
|
81
|
+
def _matching_projects(self, query: str) -> list[Project]:
|
|
82
|
+
if query:
|
|
83
|
+
return rank_matches(self.projects, query, self.usage)
|
|
84
|
+
return rank_recent(self.projects, self.usage)
|
|
85
|
+
|
|
86
|
+
def _filter(self, buffer: Buffer) -> None:
|
|
87
|
+
selected_path = self.matches[self.selected_index].path if self.matches else None
|
|
88
|
+
self.matches = self._matching_projects(buffer.text)
|
|
89
|
+
selected_index = next(
|
|
90
|
+
(index for index, project in enumerate(self.matches) if project.path == selected_path),
|
|
91
|
+
None,
|
|
92
|
+
)
|
|
93
|
+
self.selected_index = 0 if selected_index is None else selected_index
|
|
94
|
+
|
|
95
|
+
if self.application is not None:
|
|
96
|
+
self.application.invalidate()
|
|
97
|
+
|
|
98
|
+
def _move_up(self, event: KeyPressEvent) -> None:
|
|
99
|
+
self._move(event, -1)
|
|
100
|
+
|
|
101
|
+
def _move_down(self, event: KeyPressEvent) -> None:
|
|
102
|
+
self._move(event, 1)
|
|
103
|
+
|
|
104
|
+
def _move(self, event: KeyPressEvent, step: int) -> None:
|
|
105
|
+
self.selected_index = (
|
|
106
|
+
(self.selected_index + step) % len(self.matches) if self.matches else 0
|
|
107
|
+
)
|
|
108
|
+
event.app.invalidate()
|
|
109
|
+
|
|
110
|
+
def _select(self, event: KeyPressEvent) -> None:
|
|
111
|
+
selected = self.matches[self.selected_index] if self.matches else None
|
|
112
|
+
event.app.exit(result=selected)
|
|
113
|
+
|
|
114
|
+
def _cancel(self, event: KeyPressEvent) -> None:
|
|
115
|
+
event.app.exit(result=None)
|
|
116
|
+
|
|
117
|
+
def _render(self) -> StyleAndTextTuples:
|
|
118
|
+
if self.matches:
|
|
119
|
+
return self._lines()
|
|
120
|
+
|
|
121
|
+
return [("italic", "\nNo matching projects\n")]
|
|
122
|
+
|
|
123
|
+
def _lines(self) -> StyleAndTextTuples:
|
|
124
|
+
start = max(0, self.selected_index - PAGE_SIZE // 2)
|
|
125
|
+
stop = min(len(self.matches), start + PAGE_SIZE)
|
|
126
|
+
if stop - start < PAGE_SIZE:
|
|
127
|
+
start = max(0, stop - PAGE_SIZE)
|
|
128
|
+
|
|
129
|
+
lines: StyleAndTextTuples = [("", "\n")]
|
|
130
|
+
for index, project in enumerate(self.matches[start:stop], start=start):
|
|
131
|
+
selected = index == self.selected_index
|
|
132
|
+
marker = "> " if selected else " "
|
|
133
|
+
style = "reverse" if selected else ""
|
|
134
|
+
lines.append(
|
|
135
|
+
(style, f"{marker}{project.name:<24} {format_path(project.display_path)}\n")
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if len(self.matches) > PAGE_SIZE:
|
|
139
|
+
lines.append(("dim", f"\n{self.selected_index + 1}/{len(self.matches)}\n"))
|
|
140
|
+
return lines
|