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/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