niri-dev-launcher 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. #!/usr/bin/env python3
  2. """Niri Dev Launcher - Launch development environments from git repositories"""
  3. import argparse
  4. import hashlib
  5. import json
  6. import os
  7. import re
  8. import shutil
  9. import subprocess
  10. import sys
  11. import time
  12. from pathlib import Path
  13. CACHE_MAX_AGE = 300
  14. MRU_SIZE = 5
  15. def debug(msg):
  16. if os.getenv("DEBUG") == "1":
  17. print(f"[DEBUG] {msg}", file=sys.stderr)
  18. def get_cache_files(dev_dir):
  19. dev_dir_hash = hashlib.sha256(dev_dir.encode()).hexdigest()[:16]
  20. cache_dir = Path.home() / ".cache"
  21. return (
  22. cache_dir / f"niri-dev-launcher-cache-{dev_dir_hash}",
  23. cache_dir / f"niri-dev-launcher-mru-{dev_dir_hash}",
  24. )
  25. # Cache fd availability check
  26. _HAS_FD = None
  27. def _has_fd():
  28. global _HAS_FD
  29. if _HAS_FD is None:
  30. _HAS_FD = shutil.which("fd") is not None
  31. return _HAS_FD
  32. def find_git_repos(dev_dir, no_cache=False):
  33. cache_file, _ = get_cache_files(dev_dir)
  34. debug("find_git_repos: checking cache...")
  35. if not no_cache and cache_file.exists():
  36. cache_age = time.time() - os.path.getmtime(cache_file)
  37. debug(f"Cache age: {cache_age:.0f}s (max: {CACHE_MAX_AGE}s)")
  38. if cache_age < CACHE_MAX_AGE:
  39. debug("Using cached repos")
  40. return [r for r in cache_file.read_text().strip().split("\n") if r]
  41. debug("Cache expired, rescanning...")
  42. debug(f"Scanning for git repositories in {dev_dir}...")
  43. if not Path(dev_dir).exists():
  44. return []
  45. repos = []
  46. if _has_fd():
  47. debug("Using fd for scanning")
  48. result = subprocess.run(
  49. ["fd", "-H", "-t", "d", "^\.git$", dev_dir, "-d", "3", "-0"],
  50. capture_output=True, text=True,
  51. )
  52. else:
  53. debug("Using find for scanning")
  54. result = subprocess.run(
  55. ["find", dev_dir, "-maxdepth", "3", "-type", "d", "-name", ".git", "-print0"],
  56. capture_output=True, text=True,
  57. )
  58. if result.returncode == 0:
  59. repos = [str(Path(g).parent) for g in result.stdout.strip("\0").split("\0") if g]
  60. repos = sorted(set(repos))
  61. cache_file.parent.mkdir(parents=True, exist_ok=True)
  62. cache_file.write_text("\n".join(repos))
  63. debug(f"Found {len(repos)} repositories")
  64. return repos
  65. def get_project_name(repo_path):
  66. name = Path(repo_path).name.lower()
  67. name = re.sub(r"[^a-z0-9]", "-", name)
  68. name = re.sub(r"-+", "-", name)
  69. return name.strip("-")
  70. def update_mru(selected_display, dev_dir):
  71. _, mru_file = get_cache_files(dev_dir)
  72. mru_list = [l.strip() for l in mru_file.read_text().split("\n") if l.strip()] if mru_file.exists() else []
  73. # Remove if exists (using list comprehension is faster than remove+insert for small lists)
  74. mru_list = [s for s in mru_list if s != selected_display]
  75. mru_list.insert(0, selected_display)
  76. mru_file.parent.mkdir(parents=True, exist_ok=True)
  77. mru_file.write_text("\n".join(mru_list[:MRU_SIZE]))
  78. def sort_by_mru(display_list, dev_dir):
  79. _, mru_file = get_cache_files(dev_dir)
  80. mru_set = set()
  81. if mru_file.exists():
  82. mru_set = {l.strip() for l in mru_file.read_text().split("\n") if l.strip()}
  83. if not mru_set:
  84. return sorted(display_list)
  85. # Use set for O(1) lookup instead of list
  86. mru_repos = sorted([f"⭐ {d}" for d in display_list if d in mru_set])
  87. non_mru_repos = sorted([d for d in display_list if d not in mru_set])
  88. return mru_repos + non_mru_repos
  89. def find_window_by_app_id(app_id):
  90. debug(f"Searching for window with app-id: {app_id}")
  91. try:
  92. result = subprocess.run(["niri", "msg", "--json", "windows"], capture_output=True, text=True, check=False)
  93. windows_json = result.stdout.strip()
  94. if not windows_json or windows_json == "[]":
  95. return None
  96. windows = json.loads(windows_json)
  97. for window in windows:
  98. if window.get("app_id", "").lower() == app_id.lower():
  99. return str(window.get("id"))
  100. return None
  101. except (json.JSONDecodeError, subprocess.SubprocessError, KeyError):
  102. return None
  103. def focus_or_launch_alacritty(class_name, title, working_dir, session_name):
  104. debug(f"focus_or_launch_alacritty: class={class_name}, title={title}, dir={working_dir}")
  105. window_id = find_window_by_app_id(class_name)
  106. if window_id:
  107. debug("Found existing window, focusing...")
  108. subprocess.run(["niri", "msg", "action", "focus-window", "--id", window_id],
  109. stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
  110. else:
  111. debug("No existing window found, launching alacritty...")
  112. editor = os.getenv("EDITOR", "nvim")
  113. tmux_cmd = f"""if tmux has-session -t '{session_name}' 2>/dev/null; then
  114. tmux attach -t '{session_name}';
  115. else
  116. tmux new-session -c '{working_dir}' -s '{session_name}' -d '{editor}';
  117. tmux split-window -h -c '{working_dir}' -t '{session_name}';
  118. tmux select-pane -t '{session_name}:0.0';
  119. tmux attach -t '{session_name}';
  120. fi"""
  121. subprocess.Popen(
  122. ["alacritty", f"--class={class_name}", f"--title={title}",
  123. f"--working-directory={working_dir}", "-e", "bash", "-c", tmux_cmd],
  124. stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True,
  125. )
  126. def main():
  127. parser = argparse.ArgumentParser(description="Niri Dev Launcher")
  128. parser.add_argument("dev_dir", nargs="?", default=os.path.expanduser("~/Dev"), help="Development directory")
  129. parser.add_argument("--no-cache", action="store_true", help="Bypass cache and rescan repositories")
  130. parser.add_argument("--clear-cache", action="store_true", help="Clear cache and MRU files")
  131. args = parser.parse_args()
  132. debug(f"Starting niri-dev-launcher with args: {sys.argv[1:]}")
  133. debug(f"DEV_DIR: {args.dev_dir}")
  134. cache_file, mru_file = get_cache_files(args.dev_dir)
  135. debug(f"CACHE_FILE: {cache_file}")
  136. debug(f"MRU_FILE: {mru_file}")
  137. if args.clear_cache:
  138. if cache_file.exists():
  139. cache_file.unlink()
  140. if mru_file.exists():
  141. mru_file.unlink()
  142. subprocess.run(["notify-send", "Niri Dev Launcher", "Cache cleared"], check=False)
  143. sys.exit(0)
  144. repos = find_git_repos(args.dev_dir, args.no_cache)
  145. if not repos:
  146. debug("No repositories found!")
  147. subprocess.run(["notify-send", "Niri Dev Launcher", f"No git repositories found in {args.dev_dir}"], check=False)
  148. sys.exit(1)
  149. debug("Found repositories, proceeding...")
  150. display_to_path = {}
  151. display_list = []
  152. for repo in repos:
  153. display = str(Path(repo).relative_to(args.dev_dir)) if repo.startswith(args.dev_dir) else repo
  154. display_list.append(display)
  155. display_to_path[display] = repo
  156. debug(f"Mapped {len(display_to_path)} projects")
  157. debug("Sorting by MRU...")
  158. sorted_list = sort_by_mru(display_list, args.dev_dir)
  159. debug("Presenting in fuzzel...")
  160. # Use default font to avoid subprocess call - can be overridden via env if needed
  161. fuzzel_process = subprocess.Popen(
  162. ["fuzzel", "--prompt", "💀 Poison: ", "--dmenu", "--width", "30", "--lines", "20",
  163. "--border-width", "2", "--background-color", "#191724ff",
  164. "--text-color", "#e0def4ff", "--match-color", "#31748fff", "--selection-color", "#1f1d2eff",
  165. "--selection-text-color", "#31748fff", "--selection-match-color", "#31748fff", "--prompt-color", "#f6c177ff"],
  166. stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True,
  167. )
  168. stdout, _ = fuzzel_process.communicate(input="\n".join(sorted_list))
  169. selected_display = stdout.strip()
  170. debug(f"Selected display: '{selected_display}'")
  171. if not selected_display:
  172. debug("No selection, exiting")
  173. sys.exit(0)
  174. selected_display_clean = selected_display.replace("⭐ ", "", 1)
  175. selected = display_to_path.get(selected_display_clean, "")
  176. debug(f"Selected display (clean): '{selected_display_clean}'")
  177. debug(f"Selected path: '{selected}'")
  178. if not selected or not Path(selected).is_dir():
  179. debug("Invalid selection, exiting")
  180. sys.exit(0)
  181. debug("Updating MRU...")
  182. update_mru(selected_display_clean, args.dev_dir)
  183. project_name = get_project_name(selected)
  184. session_name = f"dev-{project_name}"
  185. class_name = f"com.mzunino.dev.{project_name}"
  186. debug(f"Project name: {project_name}")
  187. debug(f"Session name: {session_name}")
  188. debug(f"Class name: {class_name}")
  189. debug("Focusing or launching alacritty...")
  190. focus_or_launch_alacritty(class_name, project_name, selected, session_name)
  191. if __name__ == "__main__":
  192. main()