niri-dev-launcher 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. #!/usr/bin/env bash
  2. set -euo pipefail
  3. # Configuration
  4. CACHE_MAX_AGE=300
  5. MRU_SIZE=5
  6. # Debug logging (set DEBUG=1 to enable via environment variable)
  7. DEBUG="${DEBUG:-0}"
  8. debug() {
  9. [ "$DEBUG" = "1" ] && echo "[DEBUG] $*" >&2 || true
  10. }
  11. # Parse arguments - flags first, then DEV_DIR
  12. NO_CACHE=false
  13. CLEAR_CACHE=false
  14. EDITOR="${EDITOR:-nvim}"
  15. DEV_DIR=""
  16. debug "Starting niri-dev-launcher with args: $*"
  17. for arg in "$@"; do
  18. case "$arg" in
  19. --help | -h)
  20. cat <<EOF
  21. Usage: $0 [DEV_DIR] [OPTIONS]
  22. Options:
  23. --no-cache Bypass cache and rescan repositories
  24. --clear-cache Clear cache and MRU files
  25. --help, -h Show this help message
  26. EOF
  27. exit 0
  28. ;;
  29. --no-cache)
  30. NO_CACHE=true
  31. ;;
  32. --clear-cache)
  33. CLEAR_CACHE=true
  34. ;;
  35. *)
  36. [ -z "$DEV_DIR" ] && DEV_DIR="$arg"
  37. ;;
  38. esac
  39. done
  40. DEV_DIR="${DEV_DIR:-$HOME/Dev}"
  41. debug "DEV_DIR: $DEV_DIR"
  42. debug "NO_CACHE: $NO_CACHE"
  43. debug "CLEAR_CACHE: $CLEAR_CACHE"
  44. # Setup cache files (unique per dev directory)
  45. DEV_DIR_HASH=$(echo -n "$DEV_DIR" | sha256sum | cut -d' ' -f1 | head -c 16)
  46. CACHE_FILE="$HOME/.cache/niri-dev-launcher-cache-${DEV_DIR_HASH}"
  47. MRU_FILE="$HOME/.cache/niri-dev-launcher-mru-${DEV_DIR_HASH}"
  48. debug "CACHE_FILE: $CACHE_FILE"
  49. debug "MRU_FILE: $MRU_FILE"
  50. # Handle --clear-cache
  51. if [ "$CLEAR_CACHE" = true ]; then
  52. rm -f "$CACHE_FILE" "$MRU_FILE"
  53. notify-send "Niri Dev Launcher" "Cache cleared" 2>/dev/null || true
  54. exit 0
  55. fi
  56. # Find git repositories
  57. find_git_repos() {
  58. debug "find_git_repos: checking cache..."
  59. if [ "$NO_CACHE" = false ] && [ -f "$CACHE_FILE" ]; then
  60. local cache_age=$(($(date +%s) - $(stat -c %Y "$CACHE_FILE" 2>/dev/null || echo 0)))
  61. debug "Cache age: ${cache_age}s (max: ${CACHE_MAX_AGE}s)"
  62. if [ $cache_age -lt $CACHE_MAX_AGE ]; then
  63. debug "Using cached repos"
  64. cat "$CACHE_FILE"
  65. return
  66. fi
  67. debug "Cache expired, rescanning..."
  68. fi
  69. debug "Scanning for git repositories in $DEV_DIR..."
  70. if command -v fd >/dev/null 2>&1; then
  71. debug "Using fd for scanning"
  72. fd -H -t d "^\.git$" "$DEV_DIR" -d 3 -0 | xargs -0 -n1 dirname | sort -u >"$CACHE_FILE"
  73. else
  74. debug "Using find for scanning"
  75. find "$DEV_DIR" -maxdepth 3 -type d -name ".git" -print0 |
  76. xargs -0 -n1 dirname | sort -u >"$CACHE_FILE"
  77. fi
  78. local repo_count=$(wc -l <"$CACHE_FILE")
  79. debug "Found $repo_count repositories"
  80. cat "$CACHE_FILE"
  81. }
  82. # Get sanitized project name for tmux session
  83. get_project_name() {
  84. basename "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g'
  85. }
  86. # Update MRU list
  87. update_mru() {
  88. local selected="$1"
  89. local temp_file
  90. temp_file=$(mktemp)
  91. echo "$selected" >"$temp_file"
  92. [ -f "$MRU_FILE" ] && grep -vFx "$selected" "$MRU_FILE" >>"$temp_file" || true
  93. head -n "$MRU_SIZE" "$temp_file" >"$MRU_FILE"
  94. rm -f "$temp_file"
  95. }
  96. # Sort by MRU (MRU items first with ⭐, then others)
  97. sort_by_mru() {
  98. local all_repos="$1"
  99. local mru_list
  100. local temp_file
  101. [ -f "$MRU_FILE" ] && mru_list=$(grep -v '^$' "$MRU_FILE") || mru_list=""
  102. # If MRU list is empty, just return sorted repos
  103. if [ -z "$mru_list" ]; then
  104. echo "$all_repos" | sort
  105. return
  106. fi
  107. # Output MRU items first with indicator
  108. echo "$mru_list" | sed 's/^/⭐ /'
  109. # Output non-MRU items using comm (fast set difference)
  110. temp_file=$(mktemp)
  111. echo "$all_repos" | sort >"$temp_file.all"
  112. echo "$mru_list" | sort >"$temp_file.mru"
  113. comm -23 "$temp_file.all" "$temp_file.mru" 2>/dev/null || cat "$temp_file.all"
  114. rm -f "$temp_file.all" "$temp_file.mru"
  115. }
  116. # Find window by app-id in niri
  117. find_window_by_app_id() {
  118. local app_id="$1"
  119. debug "Searching for window with app-id: $app_id"
  120. # Check if jq is available
  121. if ! command -v jq >/dev/null 2>&1; then
  122. debug "jq not available, falling back to grep method"
  123. # Get windows list and search for matching app-id
  124. niri msg windows 2>/dev/null | grep -A 1 "App ID: \"$app_id\"" | head -1 | grep -q "Window ID" || return 1
  125. return 0
  126. fi
  127. # Get windows list from niri as JSON
  128. local windows_json
  129. windows_json=$(niri msg -j windows 2>/dev/null || echo "[]")
  130. if [ -z "$windows_json" ] || [ "$windows_json" = "[]" ]; then
  131. return 1
  132. fi
  133. # Use jq to find matching window
  134. local window_id
  135. window_id=$(echo "$windows_json" | jq -r --arg app_id "$app_id" '
  136. .[] | select(.app_id | ascii_downcase == ($app_id | ascii_downcase)) | .id
  137. ' | head -1)
  138. if [ -n "$window_id" ] && [ "$window_id" != "null" ]; then
  139. echo "$window_id"
  140. return 0
  141. fi
  142. return 1
  143. }
  144. # Focus or launch alacritty window
  145. focus_or_launch_alacritty() {
  146. local class_name="$1"
  147. local title="$2"
  148. local working_dir="$3"
  149. local session_name="$4"
  150. debug "focus_or_launch_alacritty: class=$class_name, title=$title, dir=$working_dir"
  151. # Try to find existing window
  152. local window_id
  153. window_id=$(find_window_by_app_id "$class_name" || echo "")
  154. if [ -n "$window_id" ]; then
  155. debug "Found existing window, focusing..."
  156. niri msg action focus-window --id "$window_id" >/dev/null 2>&1
  157. else
  158. debug "No existing window found, launching alacritty..."
  159. alacritty \
  160. --class="$class_name" \
  161. --title="$title" \
  162. --working-directory="$working_dir" \
  163. -e bash -c "if tmux has-session -t '$session_name' 2>/dev/null; then \
  164. tmux attach -t '$session_name'; \
  165. else \
  166. tmux new-session -c '$working_dir' -s '$session_name' -d '$EDITOR'; \
  167. tmux split-window -h -c '$working_dir' -t '$session_name'; \
  168. tmux select-pane -t '$session_name:0.0'; \
  169. tmux attach -t '$session_name'; \
  170. fi" >/dev/null 2>&1 &
  171. disown
  172. fi
  173. }
  174. # Main execution
  175. REPOS=$(find_git_repos)
  176. if [ -z "$REPOS" ]; then
  177. debug "No repositories found!"
  178. notify-send "Niri Dev Launcher" "No git repositories found in $DEV_DIR" 2>/dev/null || true
  179. exit 1
  180. fi
  181. debug "Found repositories, proceeding..."
  182. # Build display list (relative paths) and mapping
  183. DISPLAY_LIST=$(echo "$REPOS" | sed "s|^${DEV_DIR}/||")
  184. declare -A DISPLAY_TO_PATH
  185. debug "Building display mapping..."
  186. while IFS=$'\t' read -r repo display; do
  187. DISPLAY_TO_PATH["$display"]="$repo"
  188. done < <(paste <(echo "$REPOS") <(echo "$DISPLAY_LIST"))
  189. debug "Mapped ${#DISPLAY_TO_PATH[@]} projects"
  190. # Sort by MRU and present in fuzzel
  191. debug "Sorting by MRU..."
  192. SORTED_LIST=$(sort_by_mru "$DISPLAY_LIST")
  193. debug "Presenting in fuzzel..."
  194. # Configure fuzzel with similar style to original tofi
  195. SELECTED_DISPLAY=$(echo "$SORTED_LIST" | fuzzel \
  196. --prompt "💀 Poison: " \
  197. --dmenu \
  198. --width 30 \
  199. --lines 20 \
  200. --border-width 2 \
  201. --font "$(fc-match -f '%{family}:size=16' 2>/dev/null || echo 'sans:size=16')" \
  202. --background-color '#191724ff' \
  203. --text-color '#e0def4ff' \
  204. --match-color '#31748fff' \
  205. --selection-color '#1f1d2eff' \
  206. --selection-text-color '#31748fff' \
  207. --selection-match-color '#31748fff' \
  208. --prompt-color '#f6c177ff')
  209. debug "Selected display: '$SELECTED_DISPLAY'"
  210. [ -z "$SELECTED_DISPLAY" ] && debug "No selection, exiting" && exit 0
  211. # Remove star indicator and lookup path
  212. SELECTED_DISPLAY_CLEAN=$(echo "$SELECTED_DISPLAY" | sed 's/^⭐ //')
  213. SELECTED="${DISPLAY_TO_PATH[$SELECTED_DISPLAY_CLEAN]:-}"
  214. debug "Selected display (clean): '$SELECTED_DISPLAY_CLEAN'"
  215. debug "Selected path: '$SELECTED'"
  216. [ -z "$SELECTED" ] || [ ! -d "$SELECTED" ] && debug "Invalid selection, exiting" && exit 0
  217. # Update MRU and launch
  218. debug "Updating MRU..."
  219. update_mru "$SELECTED_DISPLAY_CLEAN"
  220. PROJECT_NAME=$(get_project_name "$SELECTED")
  221. SESSION_NAME="dev-${PROJECT_NAME}"
  222. CLASS_NAME="com.mzunino.dev.${PROJECT_NAME}"
  223. debug "Project name: $PROJECT_NAME"
  224. debug "Session name: $SESSION_NAME"
  225. debug "Class name: $CLASS_NAME"
  226. debug "Focusing or launching alacritty..."
  227. focus_or_launch_alacritty "$CLASS_NAME" "$PROJECT_NAME" "$SELECTED" "$SESSION_NAME"