pcd-cli 0.2.0__tar.gz → 0.2.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pcd-cli
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Fast project navigator for Bash, Zsh and Fish
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -103,10 +103,7 @@ SEE ALSO
103
103
  FEATURES.txt
104
104
 
105
105
  Planned changes:
106
- ROADMAP.md
107
-
108
- Changelog:
109
- CHANGELOG.md
106
+ ROADMAP.txt
110
107
 
111
108
  License:
112
109
  MIT
@@ -79,10 +79,7 @@ SEE ALSO
79
79
  FEATURES.txt
80
80
 
81
81
  Planned changes:
82
- ROADMAP.md
83
-
84
- Changelog:
85
- CHANGELOG.md
82
+ ROADMAP.txt
86
83
 
87
84
  License:
88
85
  MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pcd-cli"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Fast project navigator for Bash, Zsh and Fish"
5
5
  readme = { file = "README.txt", content-type = "text/plain" }
6
6
  license = "MIT"
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from pathlib import Path
4
- from typing import TYPE_CHECKING
4
+ from typing import assert_never, ClassVar, Literal, NamedTuple, TYPE_CHECKING
5
5
 
6
6
  import click
7
7
 
@@ -10,8 +10,74 @@ from pcd_cli.models import ExitCode, Project, ProjectSource
10
10
  from pcd_cli.navigation import navigate_to_project, project_completions, select_project
11
11
 
12
12
  if TYPE_CHECKING:
13
+ from collections.abc import Sequence
14
+
13
15
  from pcd_cli.catalog import ProjectCatalog
14
16
 
17
+ type ProjectSourceLabel = Literal["manual", "scanned"]
18
+ type ProjectStatusLabel = Literal["available", "missing"]
19
+
20
+
21
+ class ProjectListRow(NamedTuple):
22
+ name: str
23
+ path: str
24
+ source: ProjectSourceLabel
25
+ status: ProjectStatusLabel
26
+
27
+ @classmethod
28
+ def from_project(cls, project: Project) -> ProjectListRow:
29
+ return cls(
30
+ name=project.name,
31
+ path=format_path(project.display_path),
32
+ source=cls._source_label(project.source),
33
+ status=cls._status_label(project.path),
34
+ )
35
+
36
+ @staticmethod
37
+ def _source_label(source: ProjectSource) -> ProjectSourceLabel:
38
+ match source:
39
+ case ProjectSource.DISCOVERED:
40
+ return "scanned"
41
+ case ProjectSource.MANUAL:
42
+ return "manual"
43
+ case _:
44
+ assert_never(source)
45
+
46
+ @staticmethod
47
+ def _status_label(path: Path) -> ProjectStatusLabel:
48
+ if path.is_dir():
49
+ return "available"
50
+ else:
51
+ return "missing"
52
+
53
+
54
+ class ProjectListTable:
55
+ HEADERS: ClassVar[tuple[str, str, str, str]] = ("NAME", "PATH", "SOURCE", "STATUS")
56
+
57
+ _rows: tuple[ProjectListRow, ...]
58
+
59
+ def __init__(self, projects: Sequence[Project]) -> None:
60
+ self._rows = tuple(ProjectListRow.from_project(project) for project in projects)
61
+
62
+ def render(self) -> str:
63
+ if self._rows:
64
+ return self._render_table()
65
+ else:
66
+ return "No projects found."
67
+
68
+ def _render_table(self) -> str:
69
+ table = (self.HEADERS, *self._rows)
70
+ columns = zip(*table, strict=True)
71
+ widths = tuple(max(len(value) for value in column) for column in columns)
72
+
73
+ return "\n".join(self._render_row(row, widths) for row in table)
74
+
75
+ @staticmethod
76
+ def _render_row(row: tuple[str, ...], widths: tuple[int, ...]) -> str:
77
+ return " ".join(
78
+ value.ljust(width) for value, width in zip(row, widths, strict=True)
79
+ ).rstrip()
80
+
15
81
 
16
82
  @click.command(hidden=True)
17
83
  @click.argument("query")
@@ -101,7 +167,7 @@ def remove(catalog: ProjectCatalog, query: str) -> None:
101
167
  click.echo(f"Project not found: {query}", err=True)
102
168
  raise click.exceptions.Exit(ExitCode.NOT_FOUND)
103
169
 
104
- selected = matches[0] if len(matches) == 1 else select_project(catalog, matches, query)
170
+ selected = select_project(catalog, matches, query)
105
171
  if selected is None:
106
172
  return
107
173
 
@@ -124,17 +190,8 @@ def remove(catalog: ProjectCatalog, query: str) -> None:
124
190
  @click.pass_obj
125
191
  def list_projects(catalog: ProjectCatalog) -> None:
126
192
  """List all known projects."""
127
- projects = catalog.projects()
128
- if not projects:
129
- click.echo("No projects found.")
130
- return
131
-
132
- name_width = max(len(project.name) for project in projects)
133
- for item in projects:
134
- status = "ok" if item.path.is_dir() else "missing"
135
- click.echo(
136
- f"{item.name:<{name_width}} {format_path(item.display_path)} {item.source.value:<10} {status}"
137
- )
193
+ table = ProjectListTable(catalog.projects())
194
+ click.echo(table.render())
138
195
 
139
196
 
140
197
  @click.command()
@@ -84,17 +84,22 @@ def uninstall_shell(shell: str | None) -> None:
84
84
  required=False,
85
85
  type=click.Choice([item.value for item in Shell], case_sensitive=False),
86
86
  )
87
- def print_shell_integration(shell: str | None) -> None:
87
+ @click.pass_context
88
+ def print_shell_integration(ctx: click.Context, shell: str | None) -> None:
88
89
  """Print shell integration for manual dotfile management."""
89
90
  selected = _selected_shell(shell)
90
- click.echo(render_shell_integration(selected), nl=False)
91
+ click.echo(render_shell_integration(selected, _registered_command_names(ctx)), nl=False)
91
92
 
92
93
 
93
94
  @click.command("shell-init", hidden=True)
94
95
  @click.argument("shell", type=click.Choice([item.value for item in Shell], case_sensitive=False))
95
- def shell_init(shell: str) -> None:
96
+ @click.pass_context
97
+ def shell_init(ctx: click.Context, shell: str) -> None:
96
98
  """Backward-compatible alias for `pcd shell print`."""
97
- click.echo(render_shell_integration(Shell(shell.casefold())), nl=False)
99
+ click.echo(
100
+ render_shell_integration(Shell(shell.casefold()), _registered_command_names(ctx)),
101
+ nl=False,
102
+ )
98
103
 
99
104
 
100
105
  def _shell_integration(shell: str | None) -> ShellIntegration:
@@ -109,3 +114,11 @@ def _selected_shell(shell: str | None) -> Shell:
109
114
  return detect_shell()
110
115
  except ShellIntegrationError as exc:
111
116
  raise click.UsageError(str(exc)) from exc
117
+
118
+
119
+ def _registered_command_names(ctx: click.Context) -> tuple[str, ...]:
120
+ root = ctx.find_root()
121
+ if not isinstance(root.command, click.Group):
122
+ return ()
123
+
124
+ return tuple(root.command.list_commands(root))
@@ -31,7 +31,7 @@ def navigate_to_project(
31
31
  click.echo(f"Project not found{suffix}", err=True)
32
32
  raise click.exceptions.Exit(ExitCode.NOT_FOUND)
33
33
 
34
- selected = matches[0] if len(matches) == 1 else select_project(catalog, matches, query or "")
34
+ selected = select_project(catalog, matches, query or "")
35
35
  if selected is not None:
36
36
  _open_project(catalog, selected)
37
37
 
@@ -41,10 +41,13 @@ def select_project(
41
41
  projects: Sequence[Project],
42
42
  query: str = "",
43
43
  ) -> Project | None:
44
- # Import lazily so shell completion never initializes terminal UI machinery.
45
- from pcd_cli.picker import ProjectPicker
44
+ if len(projects) == 1:
45
+ return projects[0]
46
+ else:
47
+ # Import lazily so shell completion never initializes terminal UI machinery.
48
+ from pcd_cli.picker import ProjectPicker
46
49
 
47
- return ProjectPicker(projects, catalog.history.load(), query).run()
50
+ return ProjectPicker(projects, catalog.history.load(), query).run()
48
51
 
49
52
 
50
53
  def project_completions(value: str) -> list[CompletionItem]:
@@ -139,4 +139,5 @@ class ProjectPicker:
139
139
 
140
140
  if len(self.matches) > PAGE_SIZE:
141
141
  lines.append(("dim", f"\n{self.selected_index + 1}/{len(self.matches)}\n"))
142
+
142
143
  return lines
@@ -6,7 +6,7 @@ from dataclasses import dataclass
6
6
  from enum import StrEnum
7
7
  from pathlib import Path
8
8
  from textwrap import dedent
9
- from typing import Self
9
+ from typing import Literal, Self
10
10
 
11
11
  from pcd_cli.filesystem import atomic_write, file_lock
12
12
 
@@ -160,9 +160,18 @@ def render_managed_block(shell: Shell) -> str:
160
160
  return f"{_MANAGED_BLOCK_START}\n{command}\n{_MANAGED_BLOCK_END}\n"
161
161
 
162
162
 
163
- def render_shell_integration(shell: Shell) -> str:
163
+ def render_shell_integration(shell: Shell, direct_commands: tuple[str, ...] = ()) -> str:
164
164
  """Generate shell integration for directory changes and Click completion."""
165
- return _fish() if shell is Shell.FISH else _posix(shell)
165
+ commands = tuple(sorted(set(direct_commands)))
166
+ match shell:
167
+ case Shell.FISH:
168
+ return _fish(commands)
169
+ case Shell.BASH:
170
+ return _posix("bash_source", commands)
171
+ case Shell.ZSH:
172
+ return _posix("zsh_source", commands)
173
+ case _:
174
+ raise ValueError(f"Unsupported shell: {shell}")
166
175
 
167
176
 
168
177
  def _integration_state(content: str, shell: Shell) -> ShellIntegrationState:
@@ -200,8 +209,8 @@ def _managed_block_bounds(content: str) -> tuple[int, int] | None:
200
209
  return start, end
201
210
 
202
211
 
203
- def _posix(shell: Shell) -> str:
204
- completion = "bash_source" if shell is Shell.BASH else "zsh_source"
212
+ def _posix(completion: Literal["bash_source", "zsh_source"], commands: tuple[str, ...]) -> str:
213
+ direct_patterns = "|".join(("-*", *commands))
205
214
  return dedent(
206
215
  f"""\
207
216
  pcd() {{
@@ -210,6 +219,18 @@ def _posix(shell: Shell) -> str:
210
219
  return $?
211
220
  fi
212
221
 
222
+ if [ "$#" -eq 0 ]; then
223
+ command pcd
224
+ return $?
225
+ fi
226
+ case "$1" in
227
+ --project|--project=*) : ;;
228
+ {direct_patterns})
229
+ command pcd "$@"
230
+ return $?
231
+ ;;
232
+ esac
233
+
213
234
  local output code
214
235
  if output="$({SHELL_MODE_ENV}=1 command pcd "$@")"; then
215
236
  code=0
@@ -234,7 +255,8 @@ def _posix(shell: Shell) -> str:
234
255
  )
235
256
 
236
257
 
237
- def _fish() -> str:
258
+ def _fish(commands: tuple[str, ...]) -> str:
259
+ direct_patterns = " ".join(("'-*'", *commands))
238
260
  return dedent(
239
261
  f"""\
240
262
  function pcd
@@ -243,6 +265,18 @@ def _fish() -> str:
243
265
  return $status
244
266
  end
245
267
 
268
+ if test (count $argv) -eq 0
269
+ command pcd
270
+ return $status
271
+ end
272
+ switch $argv[1]
273
+ case '--project' '--project=*'
274
+ true
275
+ case {direct_patterns}
276
+ command pcd $argv
277
+ return $status
278
+ end
279
+
246
280
  set -l output (env {SHELL_MODE_ENV}=1 command pcd $argv)
247
281
  set -l code $status
248
282
  if test $code -eq {SHELL_CD_EXIT_CODE}
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes