dev-launcher 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. #!/usr/bin/env python3
  2. """i3 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"i3-dev-launcher-cache-{dev_dir_hash}",
  23. cache_dir / f"i3-dev-launcher-mru-{dev_dir_hash}",
  24. )
  25. _HAS_FD = None
  26. def _has_fd():
  27. global _HAS_FD
  28. if _HAS_FD is None:
  29. _HAS_FD = shutil.which("fd") is not None
  30. return _HAS_FD
  31. def find_git_repos(dev_dir, no_cache=False):
  32. cache_file, _ = get_cache_files(dev_dir)
  33. if not no_cache and cache_file.exists():
  34. if time.time() - os.path.getmtime(cache_file) < CACHE_MAX_AGE:
  35. return [r for r in cache_file.read_text().splitlines() if r]
  36. if not Path(dev_dir).exists():
  37. return []
  38. if _has_fd():
  39. result = subprocess.run(
  40. ["fd", "-H", "-t", "d", "^\\.git$", dev_dir, "-d", "3", "-0"],
  41. capture_output=True,
  42. text=True,
  43. )
  44. else:
  45. result = subprocess.run(
  46. ["find", dev_dir, "-maxdepth", "3", "-type", "d", "-name", ".git", "-print0"],
  47. capture_output=True,
  48. text=True,
  49. )
  50. repos = []
  51. if result.returncode == 0:
  52. repos = [str(Path(p).parent) for p in result.stdout.strip("\0").split("\0") if p]
  53. repos = sorted(set(repos))
  54. cache_file.parent.mkdir(parents=True, exist_ok=True)
  55. cache_file.write_text("\n".join(repos))
  56. return repos
  57. def get_project_name(repo_path):
  58. name = Path(repo_path).name.lower()
  59. name = re.sub(r"[^a-z0-9]", "-", name)
  60. name = re.sub(r"-+", "-", name)
  61. return name.strip("-")
  62. def update_mru(selected_display, dev_dir):
  63. _, mru_file = get_cache_files(dev_dir)
  64. mru = mru_file.read_text().splitlines() if mru_file.exists() else []
  65. mru = [x for x in mru if x != selected_display]
  66. mru.insert(0, selected_display)
  67. mru_file.parent.mkdir(parents=True, exist_ok=True)
  68. mru_file.write_text("\n".join(mru[:MRU_SIZE]))
  69. def sort_by_mru(display_list, dev_dir):
  70. _, mru_file = get_cache_files(dev_dir)
  71. if not mru_file.exists():
  72. return sorted(display_list)
  73. mru = [x for x in mru_file.read_text().splitlines() if x]
  74. mru_set = set(mru)
  75. starred = [f"⭐ {d}" for d in display_list if d in mru_set]
  76. rest = [d for d in display_list if d not in mru_set]
  77. return sorted(starred) + sorted(rest)
  78. def find_window_by_class(class_name):
  79. try:
  80. result = subprocess.run(
  81. ["i3-msg", "-t", "get_tree"],
  82. capture_output=True,
  83. text=True,
  84. check=False,
  85. )
  86. tree = json.loads(result.stdout)
  87. def walk(node):
  88. wp = node.get("window_properties") or {}
  89. if wp.get("class", "").lower() == class_name.lower():
  90. return node.get("id")
  91. for child in node.get("nodes", []) + node.get("floating_nodes", []):
  92. found = walk(child)
  93. if found:
  94. return found
  95. return None
  96. return walk(tree)
  97. except Exception:
  98. return None
  99. def focus_or_launch_alacritty(class_name, title, working_dir, session_name):
  100. window_id = find_window_by_class(class_name)
  101. if window_id:
  102. subprocess.run(
  103. ["i3-msg", f"[con_id={window_id}] focus"],
  104. stdout=subprocess.DEVNULL,
  105. stderr=subprocess.DEVNULL,
  106. )
  107. return
  108. editor = "nvim"
  109. tmux_cmd = f"""if tmux has-session -t '{session_name}' 2>/dev/null; then
  110. tmux attach -t '{session_name}';
  111. else
  112. tmux new-session -c '{working_dir}' -s '{session_name}' -d '{editor}';
  113. tmux split-window -h -c '{working_dir}' -t '{session_name}';
  114. tmux select-pane -t '{session_name}:0.0';
  115. tmux attach -t '{session_name}';
  116. fi"""
  117. subprocess.Popen(
  118. [
  119. "alacritty",
  120. "--class",
  121. f"{class_name},{class_name}",
  122. "--title",
  123. title,
  124. "--working-directory",
  125. working_dir,
  126. "-e",
  127. "bash",
  128. "-c",
  129. tmux_cmd,
  130. ],
  131. stdout=subprocess.DEVNULL,
  132. stderr=subprocess.DEVNULL,
  133. start_new_session=True,
  134. )
  135. def run_menu(options):
  136. if shutil.which("rofi"):
  137. proc = subprocess.Popen(
  138. ["rofi", "-dmenu", "-i", "-p", "dev", "-dpi", "1"],
  139. stdin=subprocess.PIPE,
  140. stdout=subprocess.PIPE,
  141. text=True,
  142. )
  143. else:
  144. proc = subprocess.Popen(
  145. ["dmenu", "-i", "-l", "20", "-p", "dev"],
  146. stdin=subprocess.PIPE,
  147. stdout=subprocess.PIPE,
  148. text=True,
  149. )
  150. out, _ = proc.communicate("\n".join(options))
  151. return out.strip()
  152. def main():
  153. parser = argparse.ArgumentParser(description="i3 Dev Launcher")
  154. parser.add_argument("dev_dir", nargs="?", default=os.path.expanduser("~/Dev"))
  155. parser.add_argument("--no-cache", action="store_true")
  156. parser.add_argument("--clear-cache", action="store_true")
  157. args = parser.parse_args()
  158. cache_file, mru_file = get_cache_files(args.dev_dir)
  159. if args.clear_cache:
  160. if cache_file.exists():
  161. cache_file.unlink()
  162. if mru_file.exists():
  163. mru_file.unlink()
  164. sys.exit(0)
  165. repos = find_git_repos(args.dev_dir, args.no_cache)
  166. if not repos:
  167. sys.exit(1)
  168. display_to_path = {}
  169. display_list = []
  170. for repo in repos:
  171. display = (
  172. str(Path(repo).relative_to(args.dev_dir))
  173. if repo.startswith(args.dev_dir)
  174. else repo
  175. )
  176. display_list.append(display)
  177. display_to_path[display] = repo
  178. sorted_list = sort_by_mru(display_list, args.dev_dir)
  179. selected_display = run_menu(sorted_list)
  180. if not selected_display:
  181. sys.exit(0)
  182. clean = selected_display.replace("⭐ ", "", 1)
  183. selected = display_to_path.get(clean)
  184. if not selected or not Path(selected).is_dir():
  185. sys.exit(0)
  186. update_mru(clean, args.dev_dir)
  187. project_name = get_project_name(selected)
  188. session_name = f"dev-{project_name}"
  189. class_name = f"com.mzunino.dev.{project_name}"
  190. focus_or_launch_alacritty(class_name, project_name, selected, session_name)
  191. if __name__ == "__main__":
  192. main()