dev: automated commit - 2025-11-28 21:56:48
This commit is contained in:
parent
3a4ed1d45c
commit
0aface6777
10 changed files with 413 additions and 655 deletions
|
|
@ -1,272 +1,238 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
#!/usr/bin/env python3
|
||||
"""Niri Dev Launcher - Launch development environments from git repositories"""
|
||||
|
||||
# Configuration
|
||||
CACHE_MAX_AGE=300
|
||||
MRU_SIZE=5
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# Debug logging (set DEBUG=1 to enable via environment variable)
|
||||
DEBUG="${DEBUG:-0}"
|
||||
CACHE_MAX_AGE = 300
|
||||
MRU_SIZE = 5
|
||||
|
||||
debug() {
|
||||
[ "$DEBUG" = "1" ] && echo "[DEBUG] $*" >&2 || true
|
||||
}
|
||||
|
||||
# Parse arguments - flags first, then DEV_DIR
|
||||
NO_CACHE=false
|
||||
CLEAR_CACHE=false
|
||||
EDITOR="${EDITOR:-nvim}"
|
||||
DEV_DIR=""
|
||||
def debug(msg):
|
||||
if os.getenv("DEBUG") == "1":
|
||||
print(f"[DEBUG] {msg}", file=sys.stderr)
|
||||
|
||||
debug "Starting niri-dev-launcher with args: $*"
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--help | -h)
|
||||
cat <<EOF
|
||||
Usage: $0 [DEV_DIR] [OPTIONS]
|
||||
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"niri-dev-launcher-cache-{dev_dir_hash}",
|
||||
cache_dir / f"niri-dev-launcher-mru-{dev_dir_hash}",
|
||||
)
|
||||
|
||||
Options:
|
||||
--no-cache Bypass cache and rescan repositories
|
||||
--clear-cache Clear cache and MRU files
|
||||
--help, -h Show this help message
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
--no-cache)
|
||||
NO_CACHE=true
|
||||
;;
|
||||
--clear-cache)
|
||||
CLEAR_CACHE=true
|
||||
;;
|
||||
*)
|
||||
[ -z "$DEV_DIR" ] && DEV_DIR="$arg"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
DEV_DIR="${DEV_DIR:-$HOME/Dev}"
|
||||
debug "DEV_DIR: $DEV_DIR"
|
||||
debug "NO_CACHE: $NO_CACHE"
|
||||
debug "CLEAR_CACHE: $CLEAR_CACHE"
|
||||
# Cache fd availability check
|
||||
_HAS_FD = None
|
||||
|
||||
# Setup cache files (unique per dev directory)
|
||||
DEV_DIR_HASH=$(echo -n "$DEV_DIR" | sha256sum | cut -d' ' -f1 | head -c 16)
|
||||
CACHE_FILE="$HOME/.cache/niri-dev-launcher-cache-${DEV_DIR_HASH}"
|
||||
MRU_FILE="$HOME/.cache/niri-dev-launcher-mru-${DEV_DIR_HASH}"
|
||||
debug "CACHE_FILE: $CACHE_FILE"
|
||||
debug "MRU_FILE: $MRU_FILE"
|
||||
def _has_fd():
|
||||
global _HAS_FD
|
||||
if _HAS_FD is None:
|
||||
_HAS_FD = shutil.which("fd") is not None
|
||||
return _HAS_FD
|
||||
|
||||
# Handle --clear-cache
|
||||
if [ "$CLEAR_CACHE" = true ]; then
|
||||
rm -f "$CACHE_FILE" "$MRU_FILE"
|
||||
notify-send "Niri Dev Launcher" "Cache cleared" 2>/dev/null || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Find git repositories
|
||||
find_git_repos() {
|
||||
debug "find_git_repos: checking cache..."
|
||||
if [ "$NO_CACHE" = false ] && [ -f "$CACHE_FILE" ]; then
|
||||
local cache_age=$(($(date +%s) - $(stat -c %Y "$CACHE_FILE" 2>/dev/null || echo 0)))
|
||||
debug "Cache age: ${cache_age}s (max: ${CACHE_MAX_AGE}s)"
|
||||
if [ $cache_age -lt $CACHE_MAX_AGE ]; then
|
||||
debug "Using cached repos"
|
||||
cat "$CACHE_FILE"
|
||||
return
|
||||
fi
|
||||
debug "Cache expired, rescanning..."
|
||||
fi
|
||||
def find_git_repos(dev_dir, no_cache=False):
|
||||
cache_file, _ = get_cache_files(dev_dir)
|
||||
debug("find_git_repos: checking cache...")
|
||||
|
||||
if not no_cache and cache_file.exists():
|
||||
cache_age = time.time() - os.path.getmtime(cache_file)
|
||||
debug(f"Cache age: {cache_age:.0f}s (max: {CACHE_MAX_AGE}s)")
|
||||
if cache_age < CACHE_MAX_AGE:
|
||||
debug("Using cached repos")
|
||||
return [r for r in cache_file.read_text().strip().split("\n") if r]
|
||||
debug("Cache expired, rescanning...")
|
||||
|
||||
debug(f"Scanning for git repositories in {dev_dir}...")
|
||||
if not Path(dev_dir).exists():
|
||||
return []
|
||||
|
||||
repos = []
|
||||
if _has_fd():
|
||||
debug("Using fd for scanning")
|
||||
result = subprocess.run(
|
||||
["fd", "-H", "-t", "d", "^\.git$", dev_dir, "-d", "3", "-0"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
else:
|
||||
debug("Using find for scanning")
|
||||
result = subprocess.run(
|
||||
["find", dev_dir, "-maxdepth", "3", "-type", "d", "-name", ".git", "-print0"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
repos = [str(Path(g).parent) for g in result.stdout.strip("\0").split("\0") if g]
|
||||
|
||||
repos = sorted(set(repos))
|
||||
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text("\n".join(repos))
|
||||
debug(f"Found {len(repos)} repositories")
|
||||
return repos
|
||||
|
||||
debug "Scanning for git repositories in $DEV_DIR..."
|
||||
if command -v fd >/dev/null 2>&1; then
|
||||
debug "Using fd for scanning"
|
||||
fd -H -t d "^\.git$" "$DEV_DIR" -d 3 -0 | xargs -0 -n1 dirname | sort -u >"$CACHE_FILE"
|
||||
else
|
||||
debug "Using find for scanning"
|
||||
find "$DEV_DIR" -maxdepth 3 -type d -name ".git" -print0 |
|
||||
xargs -0 -n1 dirname | sort -u >"$CACHE_FILE"
|
||||
fi
|
||||
local repo_count=$(wc -l <"$CACHE_FILE")
|
||||
debug "Found $repo_count repositories"
|
||||
cat "$CACHE_FILE"
|
||||
}
|
||||
|
||||
# Get sanitized project name for tmux session
|
||||
get_project_name() {
|
||||
basename "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g'
|
||||
}
|
||||
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("-")
|
||||
|
||||
# Update MRU list
|
||||
update_mru() {
|
||||
local selected="$1"
|
||||
local temp_file
|
||||
temp_file=$(mktemp)
|
||||
|
||||
echo "$selected" >"$temp_file"
|
||||
[ -f "$MRU_FILE" ] && grep -vFx "$selected" "$MRU_FILE" >>"$temp_file" || true
|
||||
head -n "$MRU_SIZE" "$temp_file" >"$MRU_FILE"
|
||||
rm -f "$temp_file"
|
||||
}
|
||||
def update_mru(selected_display, dev_dir):
|
||||
_, mru_file = get_cache_files(dev_dir)
|
||||
mru_list = [l.strip() for l in mru_file.read_text().split("\n") if l.strip()] if mru_file.exists() else []
|
||||
# Remove if exists (using list comprehension is faster than remove+insert for small lists)
|
||||
mru_list = [s for s in mru_list if s != selected_display]
|
||||
mru_list.insert(0, selected_display)
|
||||
mru_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
mru_file.write_text("\n".join(mru_list[:MRU_SIZE]))
|
||||
|
||||
# Sort by MRU (MRU items first with ⭐, then others)
|
||||
sort_by_mru() {
|
||||
local all_repos="$1"
|
||||
local mru_list
|
||||
local temp_file
|
||||
|
||||
[ -f "$MRU_FILE" ] && mru_list=$(grep -v '^$' "$MRU_FILE") || mru_list=""
|
||||
def sort_by_mru(display_list, dev_dir):
|
||||
_, mru_file = get_cache_files(dev_dir)
|
||||
mru_set = set()
|
||||
if mru_file.exists():
|
||||
mru_set = {l.strip() for l in mru_file.read_text().split("\n") if l.strip()}
|
||||
|
||||
if not mru_set:
|
||||
return sorted(display_list)
|
||||
|
||||
# Use set for O(1) lookup instead of list
|
||||
mru_repos = sorted([f"⭐ {d}" for d in display_list if d in mru_set])
|
||||
non_mru_repos = sorted([d for d in display_list if d not in mru_set])
|
||||
return mru_repos + non_mru_repos
|
||||
|
||||
# If MRU list is empty, just return sorted repos
|
||||
if [ -z "$mru_list" ]; then
|
||||
echo "$all_repos" | sort
|
||||
return
|
||||
fi
|
||||
|
||||
# Output MRU items first with indicator
|
||||
echo "$mru_list" | sed 's/^/⭐ /'
|
||||
def find_window_by_app_id(app_id):
|
||||
debug(f"Searching for window with app-id: {app_id}")
|
||||
try:
|
||||
result = subprocess.run(["niri", "msg", "--json", "windows"], capture_output=True, text=True, check=False)
|
||||
windows_json = result.stdout.strip()
|
||||
if not windows_json or windows_json == "[]":
|
||||
return None
|
||||
windows = json.loads(windows_json)
|
||||
for window in windows:
|
||||
if window.get("app_id", "").lower() == app_id.lower():
|
||||
return str(window.get("id"))
|
||||
return None
|
||||
except (json.JSONDecodeError, subprocess.SubprocessError, KeyError):
|
||||
return None
|
||||
|
||||
# Output non-MRU items using comm (fast set difference)
|
||||
temp_file=$(mktemp)
|
||||
echo "$all_repos" | sort >"$temp_file.all"
|
||||
echo "$mru_list" | sort >"$temp_file.mru"
|
||||
comm -23 "$temp_file.all" "$temp_file.mru" 2>/dev/null || cat "$temp_file.all"
|
||||
rm -f "$temp_file.all" "$temp_file.mru"
|
||||
}
|
||||
|
||||
# Find window by app-id in niri
|
||||
find_window_by_app_id() {
|
||||
local app_id="$1"
|
||||
debug "Searching for window with app-id: $app_id"
|
||||
def focus_or_launch_alacritty(class_name, title, working_dir, session_name):
|
||||
debug(f"focus_or_launch_alacritty: class={class_name}, title={title}, dir={working_dir}")
|
||||
window_id = find_window_by_app_id(class_name)
|
||||
|
||||
if window_id:
|
||||
debug("Found existing window, focusing...")
|
||||
subprocess.run(["niri", "msg", "action", "focus-window", "--id", window_id],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
else:
|
||||
debug("No existing window found, launching alacritty...")
|
||||
editor = os.getenv("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", f"--class={class_name}", f"--title={title}",
|
||||
f"--working-directory={working_dir}", "-e", "bash", "-c", tmux_cmd],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True,
|
||||
)
|
||||
|
||||
# Check if jq is available
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
debug "jq not available, falling back to grep method"
|
||||
# Get windows list and search for matching app-id
|
||||
niri msg windows 2>/dev/null | grep -A 1 "App ID: \"$app_id\"" | head -1 | grep -q "Window ID" || return 1
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Get windows list from niri as JSON
|
||||
local windows_json
|
||||
windows_json=$(niri msg -j windows 2>/dev/null || echo "[]")
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Niri Dev Launcher")
|
||||
parser.add_argument("dev_dir", nargs="?", default=os.path.expanduser("~/Dev"), help="Development directory")
|
||||
parser.add_argument("--no-cache", action="store_true", help="Bypass cache and rescan repositories")
|
||||
parser.add_argument("--clear-cache", action="store_true", help="Clear cache and MRU files")
|
||||
args = parser.parse_args()
|
||||
|
||||
debug(f"Starting niri-dev-launcher with args: {sys.argv[1:]}")
|
||||
debug(f"DEV_DIR: {args.dev_dir}")
|
||||
cache_file, mru_file = get_cache_files(args.dev_dir)
|
||||
debug(f"CACHE_FILE: {cache_file}")
|
||||
debug(f"MRU_FILE: {mru_file}")
|
||||
|
||||
if args.clear_cache:
|
||||
if cache_file.exists():
|
||||
cache_file.unlink()
|
||||
if mru_file.exists():
|
||||
mru_file.unlink()
|
||||
subprocess.run(["notify-send", "Niri Dev Launcher", "Cache cleared"], check=False)
|
||||
sys.exit(0)
|
||||
|
||||
repos = find_git_repos(args.dev_dir, args.no_cache)
|
||||
if not repos:
|
||||
debug("No repositories found!")
|
||||
subprocess.run(["notify-send", "Niri Dev Launcher", f"No git repositories found in {args.dev_dir}"], check=False)
|
||||
sys.exit(1)
|
||||
|
||||
debug("Found repositories, proceeding...")
|
||||
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
|
||||
|
||||
debug(f"Mapped {len(display_to_path)} projects")
|
||||
debug("Sorting by MRU...")
|
||||
sorted_list = sort_by_mru(display_list, args.dev_dir)
|
||||
debug("Presenting in fuzzel...")
|
||||
|
||||
# Use default font to avoid subprocess call - can be overridden via env if needed
|
||||
fuzzel_process = subprocess.Popen(
|
||||
["fuzzel", "--prompt", "💀 Poison: ", "--dmenu", "--width", "30", "--lines", "20",
|
||||
"--border-width", "2", "--background-color", "#191724ff",
|
||||
"--text-color", "#e0def4ff", "--match-color", "#31748fff", "--selection-color", "#1f1d2eff",
|
||||
"--selection-text-color", "#31748fff", "--selection-match-color", "#31748fff", "--prompt-color", "#f6c177ff"],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True,
|
||||
)
|
||||
|
||||
stdout, _ = fuzzel_process.communicate(input="\n".join(sorted_list))
|
||||
selected_display = stdout.strip()
|
||||
debug(f"Selected display: '{selected_display}'")
|
||||
|
||||
if not selected_display:
|
||||
debug("No selection, exiting")
|
||||
sys.exit(0)
|
||||
|
||||
selected_display_clean = selected_display.replace("⭐ ", "", 1)
|
||||
selected = display_to_path.get(selected_display_clean, "")
|
||||
debug(f"Selected display (clean): '{selected_display_clean}'")
|
||||
debug(f"Selected path: '{selected}'")
|
||||
|
||||
if not selected or not Path(selected).is_dir():
|
||||
debug("Invalid selection, exiting")
|
||||
sys.exit(0)
|
||||
|
||||
debug("Updating MRU...")
|
||||
update_mru(selected_display_clean, args.dev_dir)
|
||||
|
||||
project_name = get_project_name(selected)
|
||||
session_name = f"dev-{project_name}"
|
||||
class_name = f"com.mzunino.dev.{project_name}"
|
||||
debug(f"Project name: {project_name}")
|
||||
debug(f"Session name: {session_name}")
|
||||
debug(f"Class name: {class_name}")
|
||||
debug("Focusing or launching alacritty...")
|
||||
|
||||
focus_or_launch_alacritty(class_name, project_name, selected, session_name)
|
||||
|
||||
if [ -z "$windows_json" ] || [ "$windows_json" = "[]" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Use jq to find matching window
|
||||
local window_id
|
||||
window_id=$(echo "$windows_json" | jq -r --arg app_id "$app_id" '
|
||||
.[] | select(.app_id | ascii_downcase == ($app_id | ascii_downcase)) | .id
|
||||
' | head -1)
|
||||
|
||||
if [ -n "$window_id" ] && [ "$window_id" != "null" ]; then
|
||||
echo "$window_id"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Focus or launch alacritty window
|
||||
focus_or_launch_alacritty() {
|
||||
local class_name="$1"
|
||||
local title="$2"
|
||||
local working_dir="$3"
|
||||
local session_name="$4"
|
||||
|
||||
debug "focus_or_launch_alacritty: class=$class_name, title=$title, dir=$working_dir"
|
||||
|
||||
# Try to find existing window
|
||||
local window_id
|
||||
window_id=$(find_window_by_app_id "$class_name" || echo "")
|
||||
|
||||
if [ -n "$window_id" ]; then
|
||||
debug "Found existing window, focusing..."
|
||||
niri msg action focus-window --id "$window_id" >/dev/null 2>&1
|
||||
else
|
||||
debug "No existing window found, launching alacritty..."
|
||||
alacritty \
|
||||
--class="$class_name" \
|
||||
--title="$title" \
|
||||
--working-directory="$working_dir" \
|
||||
-e bash -c "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" >/dev/null 2>&1 &
|
||||
disown
|
||||
fi
|
||||
}
|
||||
|
||||
# Main execution
|
||||
REPOS=$(find_git_repos)
|
||||
|
||||
if [ -z "$REPOS" ]; then
|
||||
debug "No repositories found!"
|
||||
notify-send "Niri Dev Launcher" "No git repositories found in $DEV_DIR" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
debug "Found repositories, proceeding..."
|
||||
|
||||
# Build display list (relative paths) and mapping
|
||||
DISPLAY_LIST=$(echo "$REPOS" | sed "s|^${DEV_DIR}/||")
|
||||
declare -A DISPLAY_TO_PATH
|
||||
|
||||
debug "Building display mapping..."
|
||||
while IFS=$'\t' read -r repo display; do
|
||||
DISPLAY_TO_PATH["$display"]="$repo"
|
||||
done < <(paste <(echo "$REPOS") <(echo "$DISPLAY_LIST"))
|
||||
debug "Mapped ${#DISPLAY_TO_PATH[@]} projects"
|
||||
|
||||
# Sort by MRU and present in fuzzel
|
||||
debug "Sorting by MRU..."
|
||||
SORTED_LIST=$(sort_by_mru "$DISPLAY_LIST")
|
||||
debug "Presenting in fuzzel..."
|
||||
|
||||
# Configure fuzzel with similar style to original tofi
|
||||
SELECTED_DISPLAY=$(echo "$SORTED_LIST" | fuzzel \
|
||||
--prompt "💀 Poison: " \
|
||||
--dmenu \
|
||||
--width 30 \
|
||||
--lines 20 \
|
||||
--border-width 2 \
|
||||
--font "$(fc-match -f '%{family}:size=16' 2>/dev/null || echo 'sans:size=16')" \
|
||||
--background-color '#191724ff' \
|
||||
--text-color '#e0def4ff' \
|
||||
--match-color '#31748fff' \
|
||||
--selection-color '#1f1d2eff' \
|
||||
--selection-text-color '#31748fff' \
|
||||
--selection-match-color '#31748fff' \
|
||||
--prompt-color '#f6c177ff')
|
||||
|
||||
debug "Selected display: '$SELECTED_DISPLAY'"
|
||||
[ -z "$SELECTED_DISPLAY" ] && debug "No selection, exiting" && exit 0
|
||||
|
||||
# Remove star indicator and lookup path
|
||||
SELECTED_DISPLAY_CLEAN=$(echo "$SELECTED_DISPLAY" | sed 's/^⭐ //')
|
||||
SELECTED="${DISPLAY_TO_PATH[$SELECTED_DISPLAY_CLEAN]:-}"
|
||||
|
||||
debug "Selected display (clean): '$SELECTED_DISPLAY_CLEAN'"
|
||||
debug "Selected path: '$SELECTED'"
|
||||
|
||||
[ -z "$SELECTED" ] || [ ! -d "$SELECTED" ] && debug "Invalid selection, exiting" && exit 0
|
||||
|
||||
# Update MRU and launch
|
||||
debug "Updating MRU..."
|
||||
update_mru "$SELECTED_DISPLAY_CLEAN"
|
||||
|
||||
PROJECT_NAME=$(get_project_name "$SELECTED")
|
||||
SESSION_NAME="dev-${PROJECT_NAME}"
|
||||
CLASS_NAME="com.mzunino.dev.${PROJECT_NAME}"
|
||||
|
||||
debug "Project name: $PROJECT_NAME"
|
||||
debug "Session name: $SESSION_NAME"
|
||||
debug "Class name: $CLASS_NAME"
|
||||
debug "Focusing or launching alacritty..."
|
||||
|
||||
focus_or_launch_alacritty "$CLASS_NAME" "$PROJECT_NAME" "$SELECTED" "$SESSION_NAME"
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -1,109 +1,108 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple script to launch an application or focus it if already running in niri
|
||||
Usage: niri-launch-or-focus <command> [--class CLASS] [--title TITLE] [args...]
|
||||
|
||||
# Simple script to launch an application or focus it if already running in niri
|
||||
# Usage: niri-launch-or-focus <command> [--class CLASS] [--title TITLE] [args...]
|
||||
#
|
||||
# Examples:
|
||||
# niri-launch-or-focus firefox
|
||||
# niri-launch-or-focus code --class Code --title "My Project"
|
||||
# niri-launch-or-focus kitty --class kitty
|
||||
Examples:
|
||||
niri-launch-or-focus firefox
|
||||
niri-launch-or-focus code --class Code --title "My Project"
|
||||
niri-launch-or-focus kitty --class kitty
|
||||
"""
|
||||
|
||||
CLASS=""
|
||||
TITLE=""
|
||||
ARGS=()
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--class)
|
||||
CLASS="$2"
|
||||
shift 2
|
||||
;;
|
||||
--title)
|
||||
TITLE="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
ARGS+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ ${#ARGS[@]} -eq 0 ]; then
|
||||
echo "Usage: $0 <command> [--class CLASS] [--title TITLE] [args...]" >&2
|
||||
exit 1
|
||||
fi
|
||||
def find_window(class_name=None, title=None, app_name=None):
|
||||
"""Find window by app_id (class), title, or app name."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["niri", "msg", "--json", "windows"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
windows_json = result.stdout.strip()
|
||||
|
||||
if not windows_json or windows_json == "[]":
|
||||
return None
|
||||
|
||||
windows = json.loads(windows_json)
|
||||
|
||||
for window in windows:
|
||||
app_id = window.get("app_id", "").lower()
|
||||
|
||||
if class_name:
|
||||
if app_id == class_name.lower():
|
||||
return window.get("id")
|
||||
elif title:
|
||||
if window.get("title") == title:
|
||||
return window.get("id")
|
||||
elif app_name:
|
||||
if app_id == app_name.lower():
|
||||
return window.get("id")
|
||||
|
||||
return None
|
||||
except (json.JSONDecodeError, subprocess.SubprocessError, KeyError):
|
||||
return None
|
||||
|
||||
COMMAND="${ARGS[0]}"
|
||||
APP_NAME=$(basename "$COMMAND")
|
||||
unset 'ARGS[0]'
|
||||
ARGS=("${ARGS[@]}")
|
||||
|
||||
# If class/title were provided as flags, add them to command args (for apps that support them)
|
||||
if [ -n "$CLASS" ]; then
|
||||
ARGS=("--class=$CLASS" "${ARGS[@]}")
|
||||
fi
|
||||
if [ -n "$TITLE" ]; then
|
||||
ARGS=("--title=$TITLE" "${ARGS[@]}")
|
||||
fi
|
||||
def focus_window(window_id):
|
||||
"""Focus a window by ID."""
|
||||
subprocess.run(
|
||||
["niri", "msg", "action", "focus-window", "--id", str(window_id)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
# Find window by app_id (niri uses app-id), title, or app name
|
||||
find_window() {
|
||||
local class="$1"
|
||||
local title="$2"
|
||||
local app="$3"
|
||||
|
||||
# Check if jq is available
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "Error: jq is required but not found. Please install jq." >&2
|
||||
return 1
|
||||
fi
|
||||
def launch_command(command, args):
|
||||
"""Launch a command in the background."""
|
||||
subprocess.Popen(
|
||||
[command] + args,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
# Get windows list from niri as JSON
|
||||
local windows_json
|
||||
windows_json=$(niri msg -j windows 2>/dev/null || echo "[]")
|
||||
|
||||
if [ -z "$windows_json" ] || [ "$windows_json" = "[]" ]; then
|
||||
return 1
|
||||
fi
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Launch an application or focus it if already running in niri"
|
||||
)
|
||||
parser.add_argument("command", help="Command to run")
|
||||
parser.add_argument("--class", dest="class_name", help="Window class (app_id)")
|
||||
parser.add_argument("--title", help="Window title")
|
||||
parser.add_argument("args", nargs=argparse.REMAINDER, help="Additional arguments for command")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Extract app name from command (basename)
|
||||
app_name = args.command.split("/")[-1]
|
||||
|
||||
# Try to find existing window
|
||||
window_id = find_window(
|
||||
class_name=args.class_name,
|
||||
title=args.title,
|
||||
app_name=app_name if not args.class_name and not args.title else None,
|
||||
)
|
||||
|
||||
# Build command arguments
|
||||
cmd_args = []
|
||||
if args.class_name:
|
||||
cmd_args.append(f"--class={args.class_name}")
|
||||
if args.title:
|
||||
cmd_args.append(f"--title={args.title}")
|
||||
cmd_args.extend(args.args)
|
||||
|
||||
# Focus if found, otherwise launch
|
||||
if window_id:
|
||||
focus_window(window_id)
|
||||
else:
|
||||
launch_command(args.command, cmd_args)
|
||||
|
||||
# Use jq to find matching window
|
||||
local window_id
|
||||
|
||||
if [ -n "$class" ]; then
|
||||
# Match by class (app_id)
|
||||
window_id=$(echo "$windows_json" | jq -r --arg class "$class" '
|
||||
.[] | select(.app_id | ascii_downcase == ($class | ascii_downcase)) | .id
|
||||
' | head -1)
|
||||
elif [ -n "$title" ]; then
|
||||
# Match by title
|
||||
window_id=$(echo "$windows_json" | jq -r --arg title "$title" '
|
||||
.[] | select(.title == $title) | .id
|
||||
' | head -1)
|
||||
else
|
||||
# Match by app name (basename of command)
|
||||
window_id=$(echo "$windows_json" | jq -r --arg app "$app" '
|
||||
.[] | select(.app_id | ascii_downcase == ($app | ascii_downcase)) | .id
|
||||
' | head -1)
|
||||
fi
|
||||
|
||||
if [ -n "$window_id" ] && [ "$window_id" != "null" ]; then
|
||||
echo "$window_id"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Try to find existing window
|
||||
WINDOW_ID=$(find_window "$CLASS" "$TITLE" "$APP_NAME" || echo "")
|
||||
|
||||
# Focus if found, otherwise launch
|
||||
if [ -n "$WINDOW_ID" ]; then
|
||||
niri msg action focus-window --id "$WINDOW_ID" >/dev/null 2>&1
|
||||
else
|
||||
"$COMMAND" "${ARGS[@]}" >/dev/null 2>&1 &
|
||||
disown
|
||||
fi
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
97
local-bin/.local/bin/nscratch
Executable file
97
local-bin/.local/bin/nscratch
Executable file
|
|
@ -0,0 +1,97 @@
|
|||
#!/usr/bin/env python3
|
||||
# Adapted from the many ideas shared at: https://github.com/YaLTeR/niri/discussions/329
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# the found scratchpad window (id, workspace_id, is_focused, is_floating)
|
||||
scratch_window = {}
|
||||
# the focused workspace data
|
||||
focused_workspace = {}
|
||||
# the scratchpad workspace name
|
||||
scratch_workspace = os.getenv("NS_WORKSPACE", "scratch")
|
||||
|
||||
def niri_cmd(cmd_args):
|
||||
subprocess.run(["niri", "msg", "action"] + cmd_args)
|
||||
|
||||
def move_window_to_scratchpad(window_id, animations):
|
||||
niri_cmd(["move-window-to-workspace", "--window-id", str(window_id), scratch_workspace, "--focus=false"])
|
||||
if animations:
|
||||
niri_cmd(["move-window-to-tiling", "--id", str(window_id)])
|
||||
|
||||
def bring_scratchpad_window_to_focus(window_id, args):
|
||||
niri_cmd(["move-window-to-workspace", "--window-id", str(window_id), str(focused_workspace["idx"])])
|
||||
if args.animations and not scratch_window["is_floating"]:
|
||||
niri_cmd(["move-window-to-floating", "--id", str(window_id)])
|
||||
niri_cmd(["focus-window", "--id", str(window_id)])
|
||||
|
||||
def find_scratch_window(args, windows):
|
||||
for window in windows:
|
||||
if window["app_id"] == args.app_id:
|
||||
scratch_window["id"] = window["id"]
|
||||
scratch_window["workspace_id"] = window["workspace_id"]
|
||||
scratch_window["is_focused"] = window["is_focused"]
|
||||
scratch_window["is_floating"] = window["is_floating"]
|
||||
break
|
||||
|
||||
def fetch_focused_workspace():
|
||||
props = subprocess.run(
|
||||
["niri", "msg", "--json", "workspaces"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
workspaces = json.loads(props.stdout)
|
||||
|
||||
# get the focused workspace
|
||||
for workspace in workspaces:
|
||||
if workspace["is_focused"]:
|
||||
focused_workspace["idx"] = workspace["idx"]
|
||||
focused_workspace["output"] = workspace["output"]
|
||||
return workspace["id"]
|
||||
|
||||
def ns(parser):
|
||||
args = parser.parse_args()
|
||||
|
||||
props = subprocess.run(
|
||||
["niri", "msg", "--json", "windows"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
windows = json.loads(props.stdout)
|
||||
|
||||
find_scratch_window(args, windows)
|
||||
|
||||
# scratchpad does not yet exist, spawn?
|
||||
if not scratch_window:
|
||||
if args.spawn:
|
||||
niri_cmd(["spawn", "--"] + args.spawn.split(' '))
|
||||
sys.exit(0)
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
window_id = scratch_window["id"]
|
||||
|
||||
# the scratchpad window exists and it's focused
|
||||
if not scratch_window["is_focused"]:
|
||||
workspace_id = fetch_focused_workspace()
|
||||
# the window is not in the focused workspace
|
||||
if scratch_window["workspace_id"] != workspace_id:
|
||||
bring_scratchpad_window_to_focus(window_id, args)
|
||||
return
|
||||
|
||||
move_window_to_scratchpad(window_id, args.animations)
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(prog='nscratch', description='Niri Scratchpad support')
|
||||
parser.add_argument('-id', '--app-id', required=True, help='The application identifier')
|
||||
parser.add_argument('-s', '--spawn', help='The process name to spawn when non-existing')
|
||||
parser.add_argument('-a', '--animations', action='store_true', help='Enable animations')
|
||||
|
||||
ns(parser)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue