| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244 |
- #!/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", "4", "-0"],
- capture_output=True,
- text=True,
- )
- else:
- result = subprocess.run(
- ["find", dev_dir, "-maxdepth", "4", "-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()
|