dev-launcher 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. #!/usr/bin/env bash
  2. set -euo pipefail
  3. # Configuration
  4. CACHE_MAX_AGE=300
  5. MRU_SIZE=20
  6. # Parse arguments - flags first, then DEV_DIR
  7. NO_CACHE=false
  8. CLEAR_CACHE=false
  9. EDITOR="${EDITOR:-nvim}"
  10. DEV_DIR=""
  11. for arg in "$@"; do
  12. case "$arg" in
  13. --help | -h)
  14. cat <<EOF
  15. Usage: $0 [DEV_DIR] [OPTIONS]
  16. Options:
  17. --no-cache Bypass cache and rescan repositories
  18. --clear-cache Clear cache and MRU files
  19. --help, -h Show this help message
  20. EOF
  21. exit 0
  22. ;;
  23. --no-cache)
  24. NO_CACHE=true
  25. ;;
  26. --clear-cache)
  27. CLEAR_CACHE=true
  28. ;;
  29. *)
  30. [ -z "$DEV_DIR" ] && DEV_DIR="$arg"
  31. ;;
  32. esac
  33. done
  34. DEV_DIR="${DEV_DIR:-$HOME/Dev}"
  35. # Setup cache files (unique per dev directory)
  36. DEV_DIR_HASH=$(echo -n "$DEV_DIR" | sha256sum | cut -d' ' -f1 | head -c 16)
  37. CACHE_FILE="$HOME/.cache/dev-launcher-cache-${DEV_DIR_HASH}"
  38. MRU_FILE="$HOME/.cache/dev-launcher-mru-${DEV_DIR_HASH}"
  39. # Handle --clear-cache
  40. if [ "$CLEAR_CACHE" = true ]; then
  41. rm -f "$CACHE_FILE" "$MRU_FILE"
  42. notify-send "Dev Launcher" "Cache cleared" 2>/dev/null || true
  43. exit 0
  44. fi
  45. # Find git repositories
  46. find_git_repos() {
  47. if [ "$NO_CACHE" = false ] && [ -f "$CACHE_FILE" ]; then
  48. local cache_age=$(($(date +%s) - $(stat -c %Y "$CACHE_FILE" 2>/dev/null || echo 0)))
  49. [ $cache_age -lt $CACHE_MAX_AGE ] && cat "$CACHE_FILE" && return
  50. fi
  51. if command -v fd >/dev/null 2>&1; then
  52. fd -H -t d "^\.git$" "$DEV_DIR" -d 3 -0 | xargs -0 -n1 dirname | sort -u >"$CACHE_FILE"
  53. else
  54. find "$DEV_DIR" -maxdepth 3 -type d -name ".git" -print0 |
  55. xargs -0 -n1 dirname | sort -u >"$CACHE_FILE"
  56. fi
  57. cat "$CACHE_FILE"
  58. }
  59. # Get sanitized project name for tmux session
  60. get_project_name() {
  61. basename "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g'
  62. }
  63. # Update MRU list
  64. update_mru() {
  65. local selected="$1"
  66. local temp_file
  67. temp_file=$(mktemp)
  68. echo "$selected" >"$temp_file"
  69. [ -f "$MRU_FILE" ] && grep -vFx "$selected" "$MRU_FILE" >>"$temp_file" || true
  70. head -n "$MRU_SIZE" "$temp_file" >"$MRU_FILE"
  71. rm -f "$temp_file"
  72. }
  73. # Sort by MRU (MRU items first with ⭐, then others)
  74. sort_by_mru() {
  75. local all_repos="$1"
  76. local mru_list
  77. local temp_file
  78. temp_file=$(mktemp)
  79. [ -f "$MRU_FILE" ] && mru_list=$(cat "$MRU_FILE" | grep -v '^$') || mru_list=""
  80. # Output MRU items first with indicator
  81. echo "$mru_list" | sed 's/^/⭐ /'
  82. # Output non-MRU items using comm (fast set difference)
  83. echo "$all_repos" | sort >"$temp_file.all"
  84. echo "$mru_list" | sort >"$temp_file.mru"
  85. comm -23 "$temp_file.all" "$temp_file.mru" 2>/dev/null || cat "$temp_file.all"
  86. rm -f "$temp_file.all" "$temp_file.mru"
  87. }
  88. # Main execution
  89. REPOS=$(find_git_repos)
  90. [ -z "$REPOS" ] && notify-send "Dev Launcher" "No git repositories found in $DEV_DIR" 2>/dev/null && exit 1
  91. # Build display list (relative paths) and mapping
  92. DISPLAY_LIST=$(echo "$REPOS" | sed "s|^${DEV_DIR}/||")
  93. declare -A DISPLAY_TO_PATH
  94. while IFS=$'\t' read -r repo display; do
  95. DISPLAY_TO_PATH["$display"]="$repo"
  96. done < <(paste <(echo "$REPOS") <(echo "$DISPLAY_LIST"))
  97. # Sort by MRU and present in tofi
  98. SORTED_LIST=$(sort_by_mru "$DISPLAY_LIST")
  99. SELECTED_DISPLAY=$(echo "$SORTED_LIST" | tofi \
  100. --prompt-text "💀 Poison: " \
  101. --fuzzy-match true \
  102. --width 30% \
  103. --height 50% \
  104. --anchor center \
  105. --padding-left 20 \
  106. --padding-right 20 \
  107. --padding-top 15 \
  108. --padding-bottom 15 \
  109. --border-width 2 \
  110. --outline-width 0 \
  111. --font "$(fc-match -f '%{family}' 2>/dev/null || echo 'sans')" \
  112. --font-size 16 \
  113. --background-color '#191724' \
  114. --text-color '#e0def4' \
  115. --selection-color '#31748f' \
  116. --selection-background '#1f1d2e' \
  117. --border-color '#31748f' \
  118. --prompt-color '#f6c177')
  119. [ -z "$SELECTED_DISPLAY" ] && exit 0
  120. # Remove star indicator and lookup path
  121. SELECTED_DISPLAY_CLEAN=$(echo "$SELECTED_DISPLAY" | sed 's/^⭐ //')
  122. SELECTED="${DISPLAY_TO_PATH[$SELECTED_DISPLAY_CLEAN]:-}"
  123. [ -z "$SELECTED" ] || [ ! -d "$SELECTED" ] && exit 0
  124. # Update MRU and launch
  125. update_mru "$SELECTED_DISPLAY_CLEAN"
  126. PROJECT_NAME=$(get_project_name "$SELECTED")
  127. SESSION_NAME="dev-${PROJECT_NAME}"
  128. CLASS_NAME="com.mzunino.dev.${PROJECT_NAME}"
  129. launch-or-focus ghostty \
  130. --working-directory="$SELECTED" \
  131. --title="$PROJECT_NAME" \
  132. --class="$CLASS_NAME" \
  133. -e bash -c "if tmux has-session -t '$SESSION_NAME' 2>/dev/null; then \
  134. tmux attach -t '$SESSION_NAME'; \
  135. else \
  136. tmux new-session -c '$SELECTED' -s '$SESSION_NAME' -d '$EDITOR'; \
  137. tmux split-window -h -c '$SELECTED' -t '$SESSION_NAME'; \
  138. tmux select-pane -t '$SESSION_NAME:0.0'; \
  139. tmux attach -t '$SESSION_NAME'; \
  140. fi"