pcd-cli 0.1.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.
- pcd_cli-0.1.0/LICENSE +21 -0
- pcd_cli-0.1.0/PKG-INFO +105 -0
- pcd_cli-0.1.0/README.txt +82 -0
- pcd_cli-0.1.0/pyproject.toml +109 -0
- pcd_cli-0.1.0/src/pcd_cli/__init__.py +0 -0
- pcd_cli-0.1.0/src/pcd_cli/cache.py +118 -0
- pcd_cli-0.1.0/src/pcd_cli/catalog.py +157 -0
- pcd_cli-0.1.0/src/pcd_cli/cli.py +341 -0
- pcd_cli-0.1.0/src/pcd_cli/config.py +226 -0
- pcd_cli-0.1.0/src/pcd_cli/filesystem.py +79 -0
- pcd_cli-0.1.0/src/pcd_cli/history.py +95 -0
- pcd_cli-0.1.0/src/pcd_cli/models.py +39 -0
- pcd_cli-0.1.0/src/pcd_cli/navigation.py +80 -0
- pcd_cli-0.1.0/src/pcd_cli/picker.py +140 -0
- pcd_cli-0.1.0/src/pcd_cli/scanner.py +116 -0
- pcd_cli-0.1.0/src/pcd_cli/search.py +108 -0
- pcd_cli-0.1.0/src/pcd_cli/shell_integration.py +264 -0
pcd_cli-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pcd-cli contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pcd_cli-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pcd-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fast project navigator for Bash, Zsh and Fish
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: cli,cd,projects,navigation,bash,zsh,fish
|
|
8
|
+
Author: pcd-cli contributors
|
|
9
|
+
Requires-Python: >=3.14,<4
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Operating System :: MacOS
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Utilities
|
|
17
|
+
Requires-Dist: click (>=8.4.2,<9)
|
|
18
|
+
Requires-Dist: platformdirs (>=4.10,<5)
|
|
19
|
+
Requires-Dist: prompt-toolkit (>=3.0.53,<4)
|
|
20
|
+
Requires-Dist: tomlkit (>=0.15.1,<1)
|
|
21
|
+
Description-Content-Type: text/plain
|
|
22
|
+
|
|
23
|
+
Project cd
|
|
24
|
+
==========
|
|
25
|
+
|
|
26
|
+
pcd (project cd) - Is a fast way to jump between projects from your terminal
|
|
27
|
+
|
|
28
|
+
Instead of:
|
|
29
|
+
cd ~/development/github/palantir/backend/pigeon-surveillance
|
|
30
|
+
Just:
|
|
31
|
+
pcd pigeon-surveillance
|
|
32
|
+
|
|
33
|
+
Supported shells:
|
|
34
|
+
* Bash
|
|
35
|
+
* Zsh
|
|
36
|
+
* Fish
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
INSTALL
|
|
40
|
+
-------
|
|
41
|
+
pipx install --python python3.14 git+https://github.com/jestenough/pcd.git
|
|
42
|
+
pcd shell install
|
|
43
|
+
Restart your shell after installing shell integration.
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
QUICK START
|
|
47
|
+
----------
|
|
48
|
+
cd <your root folder, e.g. ~/Github/>
|
|
49
|
+
pcd init
|
|
50
|
+
pcd <project name from your root folder>
|
|
51
|
+
pcd discovers projects inside registered scan roots.
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
COMMANDS
|
|
55
|
+
--------
|
|
56
|
+
|
|
57
|
+
pcd <project> Jump to a project
|
|
58
|
+
pcd Show help
|
|
59
|
+
|
|
60
|
+
pcd add <path> Add a project manually
|
|
61
|
+
pcd remove <project> Remove a manual project
|
|
62
|
+
|
|
63
|
+
pcd init Add the current directory as a scan root
|
|
64
|
+
pcd uninit Remove the current directory from scan roots
|
|
65
|
+
pcd roots List scan roots
|
|
66
|
+
pcd refresh Rescan roots and rebuild the project cache
|
|
67
|
+
|
|
68
|
+
pcd list List all known projects
|
|
69
|
+
pcd config-path Print the configuration file path
|
|
70
|
+
|
|
71
|
+
pcd shell install Install shell integration
|
|
72
|
+
pcd shell status Show shell integration status
|
|
73
|
+
pcd shell uninstall Remove shell integration
|
|
74
|
+
|
|
75
|
+
pcd --project <project> Jump to a project whose name matches a command
|
|
76
|
+
pcd --version Show version
|
|
77
|
+
pcd --help Show help
|
|
78
|
+
|
|
79
|
+
* Projects inside registered roots are discovered automatically.
|
|
80
|
+
* You can register multiple scan roots.
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
DEVELOPMENT
|
|
84
|
+
-----------
|
|
85
|
+
make install
|
|
86
|
+
make check
|
|
87
|
+
make build
|
|
88
|
+
See ./dist folder
|
|
89
|
+
You can install *.whl with your python package manager
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
SEE ALSO
|
|
93
|
+
--------
|
|
94
|
+
Features:
|
|
95
|
+
FEATURES.txt
|
|
96
|
+
|
|
97
|
+
Planned changes:
|
|
98
|
+
ROADMAP.md
|
|
99
|
+
|
|
100
|
+
Changelog:
|
|
101
|
+
CHANGELOG.md
|
|
102
|
+
|
|
103
|
+
License:
|
|
104
|
+
MIT
|
|
105
|
+
|
pcd_cli-0.1.0/README.txt
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Project cd
|
|
2
|
+
==========
|
|
3
|
+
|
|
4
|
+
pcd (project cd) - Is a fast way to jump between projects from your terminal
|
|
5
|
+
|
|
6
|
+
Instead of:
|
|
7
|
+
cd ~/development/github/palantir/backend/pigeon-surveillance
|
|
8
|
+
Just:
|
|
9
|
+
pcd pigeon-surveillance
|
|
10
|
+
|
|
11
|
+
Supported shells:
|
|
12
|
+
* Bash
|
|
13
|
+
* Zsh
|
|
14
|
+
* Fish
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
INSTALL
|
|
18
|
+
-------
|
|
19
|
+
pipx install --python python3.14 git+https://github.com/jestenough/pcd.git
|
|
20
|
+
pcd shell install
|
|
21
|
+
Restart your shell after installing shell integration.
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
QUICK START
|
|
25
|
+
----------
|
|
26
|
+
cd <your root folder, e.g. ~/Github/>
|
|
27
|
+
pcd init
|
|
28
|
+
pcd <project name from your root folder>
|
|
29
|
+
pcd discovers projects inside registered scan roots.
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
COMMANDS
|
|
33
|
+
--------
|
|
34
|
+
|
|
35
|
+
pcd <project> Jump to a project
|
|
36
|
+
pcd Show help
|
|
37
|
+
|
|
38
|
+
pcd add <path> Add a project manually
|
|
39
|
+
pcd remove <project> Remove a manual project
|
|
40
|
+
|
|
41
|
+
pcd init Add the current directory as a scan root
|
|
42
|
+
pcd uninit Remove the current directory from scan roots
|
|
43
|
+
pcd roots List scan roots
|
|
44
|
+
pcd refresh Rescan roots and rebuild the project cache
|
|
45
|
+
|
|
46
|
+
pcd list List all known projects
|
|
47
|
+
pcd config-path Print the configuration file path
|
|
48
|
+
|
|
49
|
+
pcd shell install Install shell integration
|
|
50
|
+
pcd shell status Show shell integration status
|
|
51
|
+
pcd shell uninstall Remove shell integration
|
|
52
|
+
|
|
53
|
+
pcd --project <project> Jump to a project whose name matches a command
|
|
54
|
+
pcd --version Show version
|
|
55
|
+
pcd --help Show help
|
|
56
|
+
|
|
57
|
+
* Projects inside registered roots are discovered automatically.
|
|
58
|
+
* You can register multiple scan roots.
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
DEVELOPMENT
|
|
62
|
+
-----------
|
|
63
|
+
make install
|
|
64
|
+
make check
|
|
65
|
+
make build
|
|
66
|
+
See ./dist folder
|
|
67
|
+
You can install *.whl with your python package manager
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
SEE ALSO
|
|
71
|
+
--------
|
|
72
|
+
Features:
|
|
73
|
+
FEATURES.txt
|
|
74
|
+
|
|
75
|
+
Planned changes:
|
|
76
|
+
ROADMAP.md
|
|
77
|
+
|
|
78
|
+
Changelog:
|
|
79
|
+
CHANGELOG.md
|
|
80
|
+
|
|
81
|
+
License:
|
|
82
|
+
MIT
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pcd-cli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Fast project navigator for Bash, Zsh and Fish"
|
|
5
|
+
readme = { file = "README.txt", content-type = "text/plain" }
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "pcd-cli contributors" }]
|
|
9
|
+
requires-python = ">=3.14,<4"
|
|
10
|
+
keywords = ["cli", "cd", "projects", "navigation", "bash", "zsh", "fish"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Environment :: Console",
|
|
14
|
+
"Operating System :: MacOS",
|
|
15
|
+
"Operating System :: POSIX :: Linux",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Programming Language :: Python :: 3.14",
|
|
18
|
+
"Topic :: Utilities",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"click>=8.4.2,<9",
|
|
22
|
+
"platformdirs>=4.10,<5",
|
|
23
|
+
"prompt-toolkit>=3.0.53,<4",
|
|
24
|
+
"tomlkit>=0.15.1,<1",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
pcd = "pcd_cli.cli:main"
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["poetry-core==2.4.1"]
|
|
32
|
+
build-backend = "poetry.core.masonry.api"
|
|
33
|
+
|
|
34
|
+
[tool.poetry]
|
|
35
|
+
packages = [{ include = "pcd_cli", from = "src" }]
|
|
36
|
+
|
|
37
|
+
[tool.poetry.group.dev.dependencies]
|
|
38
|
+
mypy = "2.3.0"
|
|
39
|
+
pre-commit = "4.6.2"
|
|
40
|
+
pytest = "9.1.1"
|
|
41
|
+
pytest-cov = "7.1.0"
|
|
42
|
+
ruff = "0.16.3"
|
|
43
|
+
|
|
44
|
+
[tool.ruff]
|
|
45
|
+
target-version = "py314"
|
|
46
|
+
line-length = 100
|
|
47
|
+
src = ["src", "tests", "benchmarks"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff.lint]
|
|
50
|
+
select = [
|
|
51
|
+
"E4",
|
|
52
|
+
"E7",
|
|
53
|
+
"E9",
|
|
54
|
+
"F",
|
|
55
|
+
"I",
|
|
56
|
+
"UP",
|
|
57
|
+
"A",
|
|
58
|
+
"B",
|
|
59
|
+
"N",
|
|
60
|
+
"SIM",
|
|
61
|
+
"PERF",
|
|
62
|
+
"PIE",
|
|
63
|
+
"RUF",
|
|
64
|
+
"TC",
|
|
65
|
+
"TID",
|
|
66
|
+
"ANN",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
[tool.ruff.lint.flake8-type-checking]
|
|
70
|
+
strict = true
|
|
71
|
+
|
|
72
|
+
[tool.ruff.lint.flake8-tidy-imports.banned-api]
|
|
73
|
+
"typing.Any".msg = "Use precise types instead of Any."
|
|
74
|
+
"typing.cast".msg = "Narrow types explicitly instead of using cast()."
|
|
75
|
+
|
|
76
|
+
[tool.ruff.lint.isort]
|
|
77
|
+
known-first-party = ["pcd_cli"]
|
|
78
|
+
combine-as-imports = true
|
|
79
|
+
order-by-type = false
|
|
80
|
+
|
|
81
|
+
[tool.ruff.format]
|
|
82
|
+
quote-style = "double"
|
|
83
|
+
indent-style = "space"
|
|
84
|
+
line-ending = "lf"
|
|
85
|
+
docstring-code-format = true
|
|
86
|
+
|
|
87
|
+
[tool.mypy]
|
|
88
|
+
python_version = "3.14"
|
|
89
|
+
strict = true
|
|
90
|
+
warn_unreachable = true
|
|
91
|
+
show_error_codes = true
|
|
92
|
+
pretty = true
|
|
93
|
+
files = ["src", "tests", "benchmarks"]
|
|
94
|
+
|
|
95
|
+
[tool.pytest.ini_options]
|
|
96
|
+
addopts = "-ra --strict-config --strict-markers"
|
|
97
|
+
testpaths = ["tests"]
|
|
98
|
+
|
|
99
|
+
[tool.coverage.run]
|
|
100
|
+
branch = true
|
|
101
|
+
source = ["pcd_cli"]
|
|
102
|
+
|
|
103
|
+
[tool.coverage.report]
|
|
104
|
+
fail_under = 95
|
|
105
|
+
show_missing = true
|
|
106
|
+
skip_covered = true
|
|
107
|
+
exclude_also = [
|
|
108
|
+
"if TYPE_CHECKING:",
|
|
109
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING, TypedDict, TypeIs
|
|
5
|
+
|
|
6
|
+
from pcd_cli.filesystem import atomic_write, file_lock
|
|
7
|
+
from pcd_cli.models import Project, ProjectSource
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Iterable, Iterator
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _CacheReadError(Exception):
|
|
14
|
+
"""The on-disk cache cannot be read as a valid cache snapshot."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProjectCacheEntry(TypedDict):
|
|
18
|
+
name: str
|
|
19
|
+
path: str
|
|
20
|
+
display: str
|
|
21
|
+
source: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class ProjectCache:
|
|
26
|
+
"""Disposable JSONL index of known projects."""
|
|
27
|
+
|
|
28
|
+
path: Path
|
|
29
|
+
|
|
30
|
+
def load(self) -> list[Project] | None:
|
|
31
|
+
if not self.path.is_file():
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
return [_decode_project(entry) for entry in self._entries()]
|
|
36
|
+
except _CacheReadError:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
def find_exact(self, query: str) -> list[Project] | None:
|
|
40
|
+
if not self.path.is_file():
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
needle = query.casefold()
|
|
44
|
+
try:
|
|
45
|
+
return [
|
|
46
|
+
_decode_project(entry)
|
|
47
|
+
for entry in self._entries()
|
|
48
|
+
if entry["name"].casefold() == needle
|
|
49
|
+
]
|
|
50
|
+
except _CacheReadError:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
def find_prefix(self, query: str) -> list[Project] | None:
|
|
54
|
+
if not self.path.is_file():
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
needle = query.casefold()
|
|
58
|
+
try:
|
|
59
|
+
return [
|
|
60
|
+
_decode_project(entry)
|
|
61
|
+
for entry in self._entries()
|
|
62
|
+
if entry["name"].casefold().startswith(needle)
|
|
63
|
+
]
|
|
64
|
+
except _CacheReadError:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
def save(self, projects: Iterable[Project]) -> None:
|
|
68
|
+
# JSONL keeps refresh streaming and avoids a second in-memory JSON tree.
|
|
69
|
+
with file_lock(self.path), atomic_write(self.path) as stream:
|
|
70
|
+
for project in projects:
|
|
71
|
+
entry: ProjectCacheEntry = {
|
|
72
|
+
"name": project.name,
|
|
73
|
+
"path": str(project.path),
|
|
74
|
+
"display": str(project.display_path),
|
|
75
|
+
"source": project.source.value,
|
|
76
|
+
}
|
|
77
|
+
json.dump(entry, stream, ensure_ascii=False, separators=(",", ":"))
|
|
78
|
+
stream.write("\n")
|
|
79
|
+
|
|
80
|
+
def _entries(self) -> Iterator[ProjectCacheEntry]:
|
|
81
|
+
try:
|
|
82
|
+
with self.path.open(encoding="utf-8") as stream:
|
|
83
|
+
for line in stream:
|
|
84
|
+
try:
|
|
85
|
+
raw: object = json.loads(line)
|
|
86
|
+
except json.JSONDecodeError as exc:
|
|
87
|
+
raise _CacheReadError("Invalid cache JSON") from exc
|
|
88
|
+
if not _is_cache_entry(raw):
|
|
89
|
+
raise _CacheReadError("Invalid cache entry")
|
|
90
|
+
yield raw
|
|
91
|
+
except (OSError, UnicodeError) as exc:
|
|
92
|
+
raise _CacheReadError("Cannot read cache") from exc
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _is_cache_entry(value: object) -> TypeIs[ProjectCacheEntry]:
|
|
96
|
+
if not isinstance(value, dict):
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
isinstance(value.get("name"), str)
|
|
101
|
+
and isinstance(value.get("path"), str)
|
|
102
|
+
and isinstance(value.get("display"), str)
|
|
103
|
+
and isinstance(value.get("source"), str)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _decode_project(entry: ProjectCacheEntry) -> Project:
|
|
108
|
+
try:
|
|
109
|
+
source = ProjectSource(entry["source"])
|
|
110
|
+
except ValueError as exc:
|
|
111
|
+
raise _CacheReadError("Invalid project source") from exc
|
|
112
|
+
|
|
113
|
+
return Project(
|
|
114
|
+
name=entry["name"],
|
|
115
|
+
path=Path(entry["path"]),
|
|
116
|
+
display_path=Path(entry["display"]),
|
|
117
|
+
source=source,
|
|
118
|
+
)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Self, TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
from pcd_cli.cache import ProjectCache
|
|
5
|
+
from pcd_cli.config import Config
|
|
6
|
+
from pcd_cli.filesystem import ApplicationPaths, canonical_path
|
|
7
|
+
from pcd_cli.history import UsageHistory
|
|
8
|
+
from pcd_cli.scanner import ProjectScanner
|
|
9
|
+
from pcd_cli.search import rank_matches
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from pcd_cli.models import Project
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class ProjectCatalog:
|
|
20
|
+
"""Coordinate project configuration, discovery, cache, and usage history."""
|
|
21
|
+
|
|
22
|
+
config: Config
|
|
23
|
+
cache: ProjectCache
|
|
24
|
+
history: UsageHistory
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def create(cls) -> Self:
|
|
28
|
+
paths = ApplicationPaths.resolve()
|
|
29
|
+
return cls(
|
|
30
|
+
config=Config(paths.config),
|
|
31
|
+
cache=ProjectCache(paths.cache),
|
|
32
|
+
history=UsageHistory(paths.history),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def projects(self) -> list[Project]:
|
|
36
|
+
if cached := self.cache.load():
|
|
37
|
+
return cached
|
|
38
|
+
|
|
39
|
+
return self.refresh()
|
|
40
|
+
|
|
41
|
+
def completion_candidates(self, query: str) -> Iterator[Project]:
|
|
42
|
+
matches = self.cache.find_prefix(query)
|
|
43
|
+
if matches is not None:
|
|
44
|
+
yield from matches
|
|
45
|
+
|
|
46
|
+
def search(self, query: str, exact_only: bool = False) -> list[Project]:
|
|
47
|
+
# The common exact-hit path avoids loading the full cache and history.
|
|
48
|
+
exact_matches = self.cache.find_exact(query)
|
|
49
|
+
if exact_matches is None:
|
|
50
|
+
return self._search_projects(self.refresh(), query, exact_only)
|
|
51
|
+
|
|
52
|
+
if exact_matches:
|
|
53
|
+
if len(exact_matches) == 1:
|
|
54
|
+
return exact_matches
|
|
55
|
+
return rank_matches(exact_matches, query, self.history.load())
|
|
56
|
+
|
|
57
|
+
if exact_only:
|
|
58
|
+
return self._search_projects(self.refresh(), query, exact_only=True)
|
|
59
|
+
|
|
60
|
+
cached = self.cache.load()
|
|
61
|
+
if cached is None:
|
|
62
|
+
return self._search_projects(self.refresh(), query, exact_only=False)
|
|
63
|
+
|
|
64
|
+
usage = self.history.load()
|
|
65
|
+
matches = rank_matches(cached, query, usage)
|
|
66
|
+
if matches:
|
|
67
|
+
return matches
|
|
68
|
+
|
|
69
|
+
# A miss can mean a repository appeared after the last refresh.
|
|
70
|
+
return rank_matches(self.refresh(), query, usage)
|
|
71
|
+
|
|
72
|
+
def refresh(self) -> list[Project]:
|
|
73
|
+
settings = self.config.load()
|
|
74
|
+
projects = list(settings.manual_projects)
|
|
75
|
+
known_paths = {project.path for project in projects}
|
|
76
|
+
|
|
77
|
+
for project in ProjectScanner(settings).scan():
|
|
78
|
+
if project.path in known_paths:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
known_paths.add(project.path)
|
|
82
|
+
projects.append(project)
|
|
83
|
+
|
|
84
|
+
projects.sort(key=lambda project: (project.name.casefold(), str(project.path)))
|
|
85
|
+
self.cache.save(projects)
|
|
86
|
+
|
|
87
|
+
return projects
|
|
88
|
+
|
|
89
|
+
def record_usage(self, project: Project) -> None:
|
|
90
|
+
self.history.record(project.path)
|
|
91
|
+
|
|
92
|
+
def add_scan_root(self, root: Path) -> bool:
|
|
93
|
+
if not self.config.add_root(root):
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
self.refresh()
|
|
97
|
+
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
def remove_scan_root(self, root: Path) -> bool:
|
|
101
|
+
if not self.config.remove_root(root):
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
self.refresh()
|
|
105
|
+
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
def add_project(self, project: Project) -> bool:
|
|
109
|
+
# An authoritative scan prevents stale cache data from hiding a repository.
|
|
110
|
+
if any(item.path == project.path for item in self.refresh()):
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
if not self.config.add_project(project):
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
# Rebuild from committed config instead of extending a potentially stale snapshot.
|
|
117
|
+
self.refresh()
|
|
118
|
+
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
def remove_project(self, path: Path) -> bool:
|
|
122
|
+
if not self.config.remove_project(path):
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
self.refresh()
|
|
126
|
+
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
def find_parent_root(self, path: Path) -> Path | None:
|
|
130
|
+
target = canonical_path(path)
|
|
131
|
+
closest_root: Path | None = None
|
|
132
|
+
closest_depth = -1
|
|
133
|
+
|
|
134
|
+
for root in self.config.load().roots:
|
|
135
|
+
resolved_root = canonical_path(root)
|
|
136
|
+
if resolved_root == target or not target.is_relative_to(resolved_root):
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
root_depth = len(resolved_root.parts)
|
|
140
|
+
if root_depth > closest_depth:
|
|
141
|
+
closest_root = root
|
|
142
|
+
closest_depth = root_depth
|
|
143
|
+
|
|
144
|
+
return closest_root
|
|
145
|
+
|
|
146
|
+
def _search_projects(
|
|
147
|
+
self,
|
|
148
|
+
projects: list[Project],
|
|
149
|
+
query: str,
|
|
150
|
+
exact_only: bool,
|
|
151
|
+
) -> list[Project]:
|
|
152
|
+
needle = query.casefold()
|
|
153
|
+
exact_matches = [project for project in projects if project.name.casefold() == needle]
|
|
154
|
+
if exact_matches or exact_only:
|
|
155
|
+
return exact_matches
|
|
156
|
+
|
|
157
|
+
return rank_matches(projects, query, self.history.load())
|