#!/usr/bin/env python3 """i3 Dev Launcher - Launch development environments from git repositories""" import argparse import hashlib import json import os import re import shutil import subprocess import sys import time from pathlib import Path CACHE_MAX_AGE = 300 MRU_SIZE = 5 def debug(msg): if os.getenv("DEBUG") == "1": print(f"[DEBUG] {msg}", file=sys.stderr) def get_cache_files(dev_dir): dev_dir_hash = hashlib.sha256(dev_dir.encode()).hexdigest()[:16] cache_dir = Path.home() / ".cache" return ( cache_dir / f"i3-dev-launcher-cache-{dev_dir_hash}", cache_dir / f"i3-dev-launcher-mru-{dev_dir_hash}", ) _HAS_FD = None def _has_fd(): global _HAS_FD if _HAS_FD is None: _HAS_FD = shutil.which("fd") is not None return _HAS_FD def find_git_repos(dev_dir, no_cache=False): cache_file, _ = get_cache_files(dev_dir) if not no_cache and cache_file.exists(): if time.time() - os.path.getmtime(cache_file) < CACHE_MAX_AGE: return [r for r in cache_file.read_text().splitlines() if r] if not Path(dev_dir).exists(): return [] if _has_fd(): result = subprocess.run( ["fd", "-H", "-t", "d", "^\\.git$", dev_dir, "-d", "3", "-0"], capture_output=True, text=True, ) else: result = subprocess.run( ["find", dev_dir, "-maxdepth", "3", "-type", "d", "-name", ".git", "-print0"], capture_output=True, text=True, ) repos = [] if result.returncode == 0: repos = [str(Path(p).parent) for p in result.stdout.strip("\0").split("\0") if p] repos = sorted(set(repos)) cache_file.parent.mkdir(parents=True, exist_ok=True) cache_file.write_text("\n".join(repos)) return repos def get_project_name(repo_path): name = Path(repo_path).name.lower() name = re.sub(r"[^a-z0-9]", "-", name) name = re.sub(r"-+", "-", name) return name.strip("-") def update_mru(selected_display, dev_dir): _, mru_file = get_cache_files(dev_dir) mru = mru_file.read_text().splitlines() if mru_file.exists() else [] mru = [x for x in mru if x != selected_display] mru.insert(0, selected_display) mru_file.parent.mkdir(parents=True, exist_ok=True) mru_file.write_text("\n".join(mru[:MRU_SIZE])) def sort_by_mru(display_list, dev_dir): _, mru_file = get_cache_files(dev_dir) if not mru_file.exists(): return sorted(display_list) mru = [x for x in mru_file.read_text().splitlines() if x] mru_set = set(mru) starred = [f"⭐ {d}" for d in display_list if d in mru_set] rest = [d for d in display_list if d not in mru_set] return sorted(starred) + sorted(rest) def find_window_by_class(class_name): try: result = subprocess.run( ["i3-msg", "-t", "get_tree"], capture_output=True, text=True, check=False, ) tree = json.loads(result.stdout) def walk(node): wp = node.get("window_properties") or {} if wp.get("class", "").lower() == class_name.lower(): return node.get("id") for child in node.get("nodes", []) + node.get("floating_nodes", []): found = walk(child) if found: return found return None return walk(tree) except Exception: return None def focus_or_launch_alacritty(class_name, title, working_dir, session_name): window_id = find_window_by_class(class_name) if window_id: subprocess.run( ["i3-msg", f"[con_id={window_id}] focus"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) return editor = "nvim" tmux_cmd = f"""if tmux has-session -t '{session_name}' 2>/dev/null; then tmux attach -t '{session_name}'; else tmux new-session -c '{working_dir}' -s '{session_name}' -d '{editor}'; tmux split-window -h -c '{working_dir}' -t '{session_name}'; tmux select-pane -t '{session_name}:0.0'; tmux attach -t '{session_name}'; fi""" subprocess.Popen( [ "alacritty", "--class", f"{class_name},{class_name}", "--title", title, "--working-directory", working_dir, "-e", "bash", "-c", tmux_cmd, ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) def run_menu(options): if shutil.which("rofi"): proc = subprocess.Popen( ["rofi", "-dmenu", "-i", "-p", "dev", "-dpi", "1"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, ) else: proc = subprocess.Popen( ["dmenu", "-i", "-l", "20", "-p", "dev"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, ) out, _ = proc.communicate("\n".join(options)) return out.strip() def main(): parser = argparse.ArgumentParser(description="i3 Dev Launcher") parser.add_argument("dev_dir", nargs="?", default=os.path.expanduser("~/Dev")) parser.add_argument("--no-cache", action="store_true") parser.add_argument("--clear-cache", action="store_true") args = parser.parse_args() cache_file, mru_file = get_cache_files(args.dev_dir) if args.clear_cache: if cache_file.exists(): cache_file.unlink() if mru_file.exists(): mru_file.unlink() sys.exit(0) repos = find_git_repos(args.dev_dir, args.no_cache) if not repos: sys.exit(1) display_to_path = {} display_list = [] for repo in repos: display = ( str(Path(repo).relative_to(args.dev_dir)) if repo.startswith(args.dev_dir) else repo ) display_list.append(display) display_to_path[display] = repo sorted_list = sort_by_mru(display_list, args.dev_dir) selected_display = run_menu(sorted_list) if not selected_display: sys.exit(0) clean = selected_display.replace("⭐ ", "", 1) selected = display_to_path.get(clean) if not selected or not Path(selected).is_dir(): sys.exit(0) update_mru(clean, args.dev_dir) project_name = get_project_name(selected) session_name = f"dev-{project_name}" class_name = f"com.mzunino.dev.{project_name}" focus_or_launch_alacritty(class_name, project_name, selected, session_name) if __name__ == "__main__": main()