From 7a6635b8a2f4b2f367c44d9c61f95464b965da7e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C4=B0brahim=20=C3=87etin?= <cetinibrahim.ci@gmail.com>
Date: Sat, 15 Feb 2025 10:54:39 +0300
Subject: [PATCH] feat(pj)!: add new subcommands and autocomplete support

BREAKING CHANGE: The `pj` command has been rewritten with new subcommands.
Existing usage might no longer work. Use `pj add`, `pj mv`, `pj rm`, `pj ls`,
and `pj open -e` instead.

Co-authored-by: ibrahimcetin <mail@ibrahimcetin.dev>
---
 plugins/pj/README.md     | 114 +++++++++---
 plugins/pj/pj.plugin.zsh | 367 ++++++++++++++++++++++++++++++++++++---
 2 files changed, 440 insertions(+), 41 deletions(-)
 mode change 100644 => 100755 plugins/pj/pj.plugin.zsh

diff --git a/plugins/pj/README.md b/plugins/pj/README.md
index 27e5638ec..4161a631b 100644
--- a/plugins/pj/README.md
+++ b/plugins/pj/README.md
@@ -1,11 +1,6 @@
-# pj
+# pj plugin
 
-The `pj` plugin (short for `Project Jump`) allows you to define several
-folders where you store your projects, so that you can jump there directly
-by just using the name of the project directory.
-
-Original idea and code by Jan De Poorter ([@DefV](https://github.com/DefV))
-Source: https://gist.github.com/pjaspers/368394#gistcomment-1016
+The `pj` plugin (short for `Project Jump`) allows you to define a list of directories where your projects are located. You can quickly jump to a project directory using `pj project-name` or open it in your preferred editor with `pj open project-name`.
 
 ## Usage
 
@@ -15,31 +10,108 @@ Source: https://gist.github.com/pjaspers/368394#gistcomment-1016
    plugins=(... pj)
    ```
 
-2. Set `$PROJECT_PATHS` in your ~/.zshrc:
+2. Add project to the registry:
 
    ```zsh
-   PROJECT_PATHS=(~/src ~/work ~/"dir with spaces")
+   $ pj add
    ```
 
-You can now use one of the following commands:
+> This will add the current directory to the registry, using the directory name as the project name.
 
-##### `pj my-project`:
+3. Jump to project directory:
 
-`cd` to the directory named "my-project" found in one of the `$PROJECT_PATHS`
-directories. If there are several directories named the same, the first one
-to appear in `$PROJECT_PATHS` has preference.
+   ```zsh
+   $ pj project-name
+   ```
+> `pj` has auto-complete support for project names.
+
+4. Open the project in your defined `$EDITOR`:
+
+   ```zsh
+   $ pjo project-name
+   ```
+> Opens the project in your default $EDITOR. You can override the editor using `-e`.
+
+## Commands
+
+#### `pj <project-name>`
+
+`cd` to the project directory with the given name. Note: you can use auto-complete for project names.
 
 For example:
 ```zsh
-PROJECT_PATHS=(~/code ~/work)
-$ ls ~/code    # ~/code/blog ~/code/react
-$ ls ~/work    # ~/work/blog ~/work/project
-$ pj blog      # <-- will cd to ~/code/blog
+$ pj my-project
 ```
 
-##### `pjo my-project`
+#### `pj add [path] [name]`
 
-Open the project directory with your defined `$EDITOR`. This follows the same
-directory rules as the `pj` command above.
+Add a project to the registry.
+Note: `pja` is an alias of `pj add`.
 
+For example:
+```zsh
+$ pja
+$ # Add the current directory with the name "my-project"
+$ pja . my-project
+$ # Add the specified directory to the registry with the name "my-project"
+$ pja /path/to/project my-project
+```
+
+##### `pj open <project-name>`
+
+Open the project with your defined `$EDITOR` or specify an editor with the `-e` flag.
 Note: `pjo` is an alias of `pj open`.
+
+For example:
+```zsh
+$ pjo my-project
+$ # open the project path named "my-project" with VSCode
+$ pjo -e code my-project
+$ # open multiple projects
+$ pjo my-project another-project
+```
+
+##### `pj ls [pattern]`
+
+List all the projects in the registry.
+Note: `pjl` is an alias of `pj ls`.
+
+For example:
+```zsh
+$ pj ls
+$ # list all the projects in the registry that match the pattern 'web-*'
+$ pjl 'web-*'
+```
+
+##### `pj rm <project-name>`
+
+Remove a project from the registry.
+
+For example:
+```zsh
+$ pj rm my-project
+$ # remove multiple projects from the registry
+$ pj rm my-project another-project
+```
+
+#### `pj mv <old-name> <new-name>`
+
+Rename a project in the registry.
+
+For example:
+```zsh
+$ pj mv old-name new-name
+```
+
+## Aliases
+| Alias | Command |
+|-------|---------|
+| `pja` | `pj add` |
+| `pjo` | `pj open` |
+| `pjl` | `pj ls` |
+
+## Contributors
+Code by [@ibrahimcetin](https://github.com/ibrahimcetin)
+
+Original idea and code by Jan De Poorter ([@DefV](https://github.com/DefV))
+Source: https://gist.github.com/pjaspers/368394#gistcomment-1016
\ No newline at end of file
diff --git a/plugins/pj/pj.plugin.zsh b/plugins/pj/pj.plugin.zsh
old mode 100644
new mode 100755
index 431576f4b..afb233d7c
--- a/plugins/pj/pj.plugin.zsh
+++ b/plugins/pj/pj.plugin.zsh
@@ -1,34 +1,361 @@
-alias pjo="pj open"
+PJ_DB=~/.pj_projects  # Project database file
+touch "$PJ_DB"
 
-function pj() {
-  local cmd="cd"
-  local project="$1"
+_pj_help() {
+  cat <<EOF
+Project Jump (pj) - Quick directory navigation for projects
 
-  if [[ "open" == "$project" ]]; then
-    shift
-    project=$*
-    cmd=${=EDITOR}
-  else
-    project=$*
+Usage:
+  pj PROJECT_NAME             Jump to a project directory
+  pj add [PATH] [NAME]        Add a project to registry. Defaults to current directory
+  pj open [-e] (NAME ...)     Open project(s) in editor. Uses \$EDITOR (${EDITOR:-not set}) by default
+  pj ls [PATTERN]             List all projects. Use PATTERN to filter results
+  pj rm (PROJECT_NAME ...)    Remove project(s) from registry
+  pj mv OLD_NAME NEW_NAME     Rename a project in registry
+  pj help                     Show this help message
+
+Examples:
+  pj add                      # Add current directory
+  pj add . my-project         # Add current directory as 'my-project'
+  pj rm my-project            # Remove my-project from registry
+
+  pj open my-project          # Open my-project in editor
+  pj open my-project my-other # Open multiple projects
+  pj open -e vim my-project   # Open my-project in nano. Specify editor with -e
+
+  pj ls                       # List all projects
+  pj ls my-project            # List projects matching 'my-project'
+  pj ls 'subfolder/*my*'      # List projects matching 'subfolder/*my*'. Use quotes for globs
+
+Aliases:
+  pja - pj add
+  pjo - pj open
+  pjl - pj ls
+EOF
+}
+
+_pj_jump() {
+  local project_name="$1"
+  local target_path
+
+  # Check for extra arguments
+  if [[ $# -gt 1 ]]; then
+    echo "Error: Too many arguments" >&2
+    echo "Usage: pj [PROJECT_NAME]" >&2
+    return 1
   fi
 
-  for basedir ($PROJECT_PATHS); do
-    if [[ -d "$basedir/$project" ]]; then
-      $cmd "$basedir/$project"
-      return
+  # Validate input
+  if [[ -z "$project_name" ]]; then
+    echo "Error: Missing project name" >&2
+    echo "Usage: pj [PROJECT_NAME]" >&2
+    return 1
+  fi
+
+  # Find project in database
+  if [[ -f "$PJ_DB" ]]; then
+    target_path=$(awk -F: -v project="$project_name" '
+      $1 == project {print $2; exit}
+    ' "$PJ_DB")
+  fi
+
+  # Handle found project
+  if [[ -n "$target_path" ]]; then
+    if [[ -d "$target_path" ]]; then
+      cd "$target_path" || {
+        echo "Error: Failed to access $target_path" >&2
+        return 1
+      }
+      return 0
+    else
+      echo "Error: Path not exists for project '$project_name'" >&2
+      echo "Path: $target_path" >&2
+      return 1
+    fi
+  fi
+
+  # Project not found
+  echo "Error: Project not found - $project_name" >&2
+  return 1
+}
+
+_pj_add() {
+  local path_input=${1:-.}
+  local name=${2:-}
+  local resolved_path="${path_input:a}"
+  local default_name="${resolved_path:t}"
+
+  # Check for extra arguments
+  if [[ $# -gt 2 ]]; then
+    echo "Error: Too many arguments" >&2
+    echo "Usage: pj add [PATH] [NAME]" >&2
+    return 1
+  fi
+
+  # Check if the name is a command name
+  case "$name" in
+    add|rm|ls|open|mv|help)
+      echo "Error: Invalid project name '$name'. It conflicts with a command name." >&2
+      return 1
+      ;;
+  esac
+
+  # Validate directory exists
+  if [[ ! -d "$resolved_path" ]]; then
+    echo "Error: Invalid directory path '$path_input'" >&2
+    echo "Resolved to: $resolved_path" >&2
+    return 1
+  fi
+
+  # Set default name if not provided
+  if [[ -z "$name" ]]; then
+    name="$default_name"
+  fi
+
+  # Validate name format
+  if [[ "$name" =~ [^a-zA-Z0-9_-] ]]; then
+    echo "Error: Invalid name '$name'" >&2
+    echo "Allowed characters: A-Z, 0-9, -, _" >&2
+    return 1
+  fi
+
+  # Check for existing project name
+  if [[ -f "$PJ_DB" ]] && grep -q "^${name}:" "$PJ_DB"; then
+    local existing_path=$(awk -F: -v n="$name" '$1 == n {print $2}' "$PJ_DB")
+    echo "Error: Project name '$name' already exists" >&2
+    echo "Existing path: $existing_path" >&2
+    return 1
+  fi
+
+  # Add new entry
+  echo "${name}:${resolved_path}" >> "$PJ_DB"
+  echo "Added project: ${name} -> ${resolved_path}"
+}
+
+_pj_ls() {
+  local pattern="${1:-*}"  # Default to all projects
+  local -a projects=()
+  local name path
+
+  # Read database file
+  while IFS=: read -r name path; do
+    # Case-insensitive glob matching
+    if [[ "${name:l}" == *${~pattern:l}* || \
+          "${path:l}" == *${~pattern:l}* ]]; then
+      projects+=("${name} -> ${path}")
+    fi
+  done < "$PJ_DB" 2>/dev/null
+
+  # Process projects using Zsh built-ins
+  if (( ${#projects} > 0 )); then
+    # Remove duplicates and sort
+    projects=(${(u)projects})  # Remove duplicates
+    projects=(${(o)projects})  # Sort alphabetically
+
+    # Print results
+    print -l "${projects[@]}"
+  fi
+}
+
+_pj_open() {
+  local editor="$EDITOR"
+  local project_names=()
+  local target_path
+  local errors=0
+
+  # Parse options
+  while [[ "$1" =~ ^- ]]; do
+    case "$1" in
+      -e|--editor)
+        shift
+        editor="$1"
+        ;;
+      *)
+        echo "Error: Invalid option $1" >&2
+        echo "Usage: pj open [-e editor] (PROJECT_NAME ...)" >&2
+        return 1
+        ;;
+    esac
+    shift
+  done
+
+  # Collect project names
+  project_names=("$@")
+
+  # Validate input
+  if [[ ${#project_names[@]} -eq 0 ]]; then
+    echo "Error: Missing project name(s)" >&2
+    echo "Usage: pj open [-e editor] [PROJECT_NAME ...]" >&2
+    return 1
+  fi
+
+  # Validate editor
+  if [[ -z "$editor" ]]; then
+    echo "Error: No editor specified" >&2
+    echo "Set \$EDITOR or use -e option" >&2
+    return 1
+  fi
+
+  for project_name in "${project_names[@]}"; do
+    # Find project in database
+    target_path=$(awk -F: -v project="$project_name" '$1 == project {print $2; exit}' "$PJ_DB")
+
+    # Handle project path
+    if [[ -n "$target_path" ]]; then
+      if [[ -d "$target_path" ]]; then
+        ${=editor} "$target_path"
+      else
+        echo "Error: Invalid path for project '$project_name'" >&2
+        echo "Path: $target_path" >&2
+        errors=$((errors + 1))
+      fi
+    else
+      echo "Error: Project not found - $project_name" >&2
+      errors=$((errors + 1))
     fi
   done
 
-  echo "No such project '${project}'."
+  return $errors
 }
 
-_pj () {
-  local -a projects
-  for basedir ($PROJECT_PATHS); do
-    projects+=(${basedir}/*(/N))
+_pj_rm() {
+  local project_names=("$@")
+  local errors=0
+
+  # Validate input
+  if [[ ${#project_names[@]} -eq 0 ]]; then
+    echo "Error: Missing project name(s)" >&2
+    echo "Usage: pj rm [PROJECT_NAME ...]" >&2
+    return 1
+  fi
+
+  for project_name in "${project_names[@]}"; do
+    # Verify project exists
+    if ! grep -q "^${project_name}:" "$PJ_DB"; then
+      echo "Error: Project not found - $project_name" >&2
+      errors=$((errors + 1))
+      continue
+    fi
+
+    # Remove entry from database
+    local project_path=$(awk -F: -v project="$project_name" '$1 == project {print $2; exit}' "$PJ_DB")
+    sed -i.bak "/^${project_name}:/d" "$PJ_DB" && rm -f "$PJ_DB.bak"
+    echo "Removed project: $project_name -> $project_path"
   done
 
-  compadd ${projects:t}
+  return $errors
 }
 
+_pj_mv() {
+  local project_name="$1"
+  local new_name="$2"
+
+  # Validate input
+  if [[ -z "$project_name" || -z "$new_name" ]]; then
+    echo "Error: Missing project name or new name" >&2
+    echo "Usage: pj mv [PROJECT_NAME] [NEW_NAME]" >&2
+    return 1
+  fi
+
+  # Verify project exists
+  if ! grep -q "^${project_name}:" "$PJ_DB"; then
+    echo "Error: Project not found - $project_name" >&2
+    return 1
+  fi
+
+  # Verify new name is not taken
+  if grep -q "^${new_name}:" "$PJ_DB"; then
+    echo "Error: New project name already exists - $new_name" >&2
+    return 1
+  fi
+
+  # Move project
+  sed -i.bak "s/^${project_name}:/${new_name}:/g" "$PJ_DB" && rm -f "$PJ_DB.bak"
+  echo "Moved project: $project_name -> $new_name"
+}
+
+# Main function
+function pj() {
+  if [ $# -eq 0 ]; then
+    _pj_help
+    return 1
+  fi
+
+  # Command dispatch
+  case "$1" in
+    "add")
+      shift  # Remove 'add' from arguments
+      _pj_add "$@"
+      ;;
+    "rm")
+      shift
+      _pj_rm "$@"
+      ;;
+    "ls")
+      shift
+      _pj_ls "$@"
+      ;;
+    "open")
+      shift
+      _pj_open "$@"
+      ;;
+    "mv")
+      shift
+      _pj_mv "$@"
+      ;;
+    "help"|"--help"|"-h"|"")
+      _pj_help
+      return 1
+      ;;
+    *)
+      _pj_jump "$@"
+      ;;
+  esac
+}
+
+# Main completion entry point
+_pj() {
+  local context state state_descr line
+  typeset -A opt_args
+
+  # Parse command line state
+  _arguments -C \
+    '1: :->command_or_project' \
+    '*: :->args'
+
+  case $state in
+    (command_or_project)
+      _pj_show_projects
+      ;;
+    (args)
+      _pj_handle_subcommand_args
+      ;;
+  esac
+}
+
+# Helper: Handle arguments after subcommand
+_pj_handle_subcommand_args() {
+  case $line[1] in
+    (add)
+      _files -/
+      ;;
+
+    (open|rm|ls|mv)
+      _pj_show_projects
+      ;;
+  esac
+}
+
+# Helper: Show just project names
+_pj_show_projects() {
+  local projects=(${(f)"$(cut -d: -f1 "$PJ_DB" 2>/dev/null)"})
+  _describe 'projects' projects
+}
+
+# Register completion
 compdef _pj pj
+
+# Editor aliases
+alias pja="pj add"
+alias pjo="pj open"
+alias pjl="pj ls"