From 0b4c2efc8f4298168befc24d6a55cb732772caae Mon Sep 17 00:00:00 2001 From: m3tm3re Date: Fri, 2 Jan 2026 14:54:12 +0100 Subject: [PATCH] feat: add rofi-project-opener for rofi-based project launching Adds package and HM module for quickly opening projects in terminals. Uses XDG config file instead of environment variables for better shell-independence and proper NixOS PATH sourcing. --- modules/home-manager/cli/default.nix | 1 + .../home-manager/cli/rofi-project-opener.nix | 90 ++++++++++ pkgs/default.nix | 1 + pkgs/rofi-project-opener/default.nix | 165 ++++++++++++++++++ 4 files changed, 257 insertions(+) create mode 100644 modules/home-manager/cli/rofi-project-opener.nix create mode 100644 pkgs/rofi-project-opener/default.nix diff --git a/modules/home-manager/cli/default.nix b/modules/home-manager/cli/default.nix index 52b4631..a8fe362 100644 --- a/modules/home-manager/cli/default.nix +++ b/modules/home-manager/cli/default.nix @@ -1,6 +1,7 @@ # CLI/Terminal-related Home Manager modules { imports = [ + ./rofi-project-opener.nix ./stt-ptt.nix ./zellij-ps.nix ]; diff --git a/modules/home-manager/cli/rofi-project-opener.nix b/modules/home-manager/cli/rofi-project-opener.nix new file mode 100644 index 0000000..94d0648 --- /dev/null +++ b/modules/home-manager/cli/rofi-project-opener.nix @@ -0,0 +1,90 @@ +{ + config, + lib, + pkgs, + ... +}: +with lib; let + cfg = config.cli.rofi-project-opener; + + # Convert list of paths to colon-separated string + projectDirsStr = concatStringsSep ":" cfg.projectDirs; +in { + options.cli.rofi-project-opener = { + enable = mkEnableOption "Rofi-based project directory launcher"; + + projectDirs = mkOption { + type = types.listOf types.str; + default = ["~/dev" "~/projects"]; + description = '' + List of base directories to scan for project subdirectories. + Each directory will be scanned for immediate subdirectories (non-hidden). + Projects are displayed as "base_dir/project_name" in rofi. + + Supports ~ for home directory expansion. + ''; + example = literalExpression ''["~/dev" "~/projects" "~/code"]''; + }; + + terminal = mkOption { + type = types.either types.str types.package; + default = "kitty"; + description = "Terminal emulator to use for launching opencode. Can be a string or package."; + example = literalExpression "pkgs.alacritty"; + }; + + terminalCommand = mkOption { + type = types.str; + default = ""; + description = '' + Custom command to pass to the terminal. + + Use %s as a placeholder for the project path. + If empty, defaults to opening a shell in the project directory and running opencode. + + Examples: + - "" (empty) - Uses default: cd to project, run opencode + - "-e zsh -c 'cd %s && opencode'" - Custom shell with explicit path + - "--hold -e nvim" - Open editor directly (no %s = no cd) + ''; + example = literalExpression ''"-e zsh -c 'cd %s && opencode'"''; + }; + + rofiPrompt = mkOption { + type = types.str; + default = "Select project"; + description = "Prompt text displayed in rofi."; + example = "Open project:"; + }; + + rofiArgs = mkOption { + type = types.listOf types.str; + default = ["-dmenu" "-i"]; + description = '' + Arguments to pass to rofi. + + Common options: + - "-dmenu" - Enable dmenu mode (required) + - "-i" - Case-insensitive matching + - "-theme " - Use specific rofi theme + - "-width " - Window width + - "-lines " - Number of visible lines + ''; + example = literalExpression ''["-dmenu" "-i" "-theme gruvbox"]''; + }; + }; + + config = mkIf cfg.enable { + home.packages = [pkgs.rofi-project-opener]; + + # Write config file (shell-independent) + xdg.configFile."rofi-project-opener/config".text = '' + # rofi-project-opener configuration + PROJECT_DIRS="${projectDirsStr}" + TERMINAL="${if isDerivation cfg.terminal then "${cfg.terminal}/bin/${cfg.terminal.pname or (builtins.baseNameOf (toString cfg.terminal))}" else cfg.terminal}" + ${optionalString (cfg.terminalCommand != "") ''TERMINAL_CMD="${cfg.terminalCommand}"''} + ROFI_PROMPT="${cfg.rofiPrompt}" + ROFI_ARGS="${escapeShellArgs cfg.rofiArgs}" + ''; + }; +} diff --git a/pkgs/default.nix b/pkgs/default.nix index 6f794c9..4b93847 100644 --- a/pkgs/default.nix +++ b/pkgs/default.nix @@ -7,6 +7,7 @@ mem0 = pkgs.callPackage ./mem0 {}; msty-studio = pkgs.callPackage ./msty-studio {}; pomodoro-timer = pkgs.callPackage ./pomodoro-timer {}; + rofi-project-opener = pkgs.callPackage ./rofi-project-opener {}; stt-ptt = pkgs.callPackage ./stt-ptt {}; tuxedo-backlight = pkgs.callPackage ./tuxedo-backlight {}; zellij-ps = pkgs.callPackage ./zellij-ps {}; diff --git a/pkgs/rofi-project-opener/default.nix b/pkgs/rofi-project-opener/default.nix new file mode 100644 index 0000000..8637ee9 --- /dev/null +++ b/pkgs/rofi-project-opener/default.nix @@ -0,0 +1,165 @@ +{ + lib, + writeShellScriptBin, + rofi, + libnotify, + coreutils, + gnugrep, + gnused, +}: +writeShellScriptBin "rofi-project-opener" '' + #!/usr/bin/env bash + # rofi-project-opener - Rofi-based project directory launcher + + set -euo pipefail + + # Core utilities + NOTIFY="${libnotify}/bin/notify-send" + ROFI="${rofi}/bin/rofi" + MKDIR="${coreutils}/bin/mkdir" + RM="${coreutils}/bin/rm" + SORT="${coreutils}/bin/sort" + CUT="${coreutils}/bin/cut" + DIRNAME="${coreutils}/bin/dirname" + BASENAME="${coreutils}/bin/basename" + GREP="${gnugrep}/bin/grep" + SED="${gnused}/bin/sed" + + # Configuration from config file or environment variables + CONFIG_FILE="''${XDG_CONFIG_HOME:-$HOME/.config}/rofi-project-opener/config" + + if [[ -f "$CONFIG_FILE" ]]; then + # shellcheck disable=SC1090 + source "$CONFIG_FILE" + fi + + # Fallback to environment variables or defaults + PROJECT_DIRS="''${PROJECT_DIRS:-$HOME}" + TERMINAL="''${TERMINAL:-kitty}" + TERMINAL_CMD="''${TERMINAL_CMD:-}" + ROFI_PROMPT="''${ROFI_PROMPT:-Select project}" + ROFI_ARGS="''${ROFI_ARGS:--dmenu -i}" + + # Temporary file for project list + PROJECTS_LIST="''${XDG_RUNTIME_DIR:-/tmp}/rofi-project-opener.$$" + + # Cleanup on exit + cleanup() { + [[ -f "$PROJECTS_LIST" ]] && "$RM" -f "$PROJECTS_LIST" + } + trap cleanup EXIT + + # Ensure runtime directory exists + "$MKDIR" -p "$("$DIRNAME" "$PROJECTS_LIST")" + + # Show error notification + show_error() { + local message="$1" + echo "[rofi-project-opener] Error: $message" >&2 + "$NOTIFY" -u critical -a "rofi-project-opener" "$message" + } + + # Build list of projects + build_project_list() { + > "$PROJECTS_LIST" + + # Split PROJECT_DIRS by colon and iterate + IFS=':' read -ra DIR_ARRAY <<< "$PROJECT_DIRS" + for base_dir in "''${DIR_ARRAY[@]}"; do + # Expand ~ to $HOME + base_dir="''${base_dir/#\~/$HOME}" + # Expand $HOME variable (allows both ~ and $HOME in config) + base_dir="$(eval echo "$base_dir")" + + # Skip if directory doesn't exist + if [[ ! -d "$base_dir" ]]; then + echo "[rofi-project-opener] Warning: Directory does not exist: $base_dir" >&2 + continue + fi + + # Find 1st level subdirectories (non-hidden) + for project in "$base_dir"/*/; do + if [[ -d "$project" ]]; then + # Get directory name without trailing slash + project_path="''${project%/}" + project_name="$("$BASENAME" "$project_path")" + + # Skip hidden directories + if [[ "$project_name" == .* ]]; then + continue + fi + + base_display="$("$BASENAME" "$base_dir")" + echo "$base_display/$project_name|$project_path" >> "$PROJECTS_LIST" + fi + done + done + + # Check if we found any projects + [[ -s "$PROJECTS_LIST" ]] + } + + # Build project list + if ! build_project_list; then + show_error "No projects found in PROJECT_DIRS: $PROJECT_DIRS" + exit 1 + fi + + # Deduplicate and sort + "$SORT" -t '|' -k1 -u "$PROJECTS_LIST" -o "$PROJECTS_LIST" + + # Display in rofi and get selection + # shellcheck disable=SC2086 + selection=$("$CUT" -d '|' -f1 "$PROJECTS_LIST" | "$ROFI" $ROFI_ARGS -p "$ROFI_PROMPT") || true + + # Exit if cancelled + if [[ -z "$selection" ]]; then + exit 0 + fi + + # Get the full path from selection + project_path=$("$GREP" "^$selection|" "$PROJECTS_LIST" | "$CUT" -d '|' -f2) + + # Exit if path not found (shouldn't happen) + if [[ -z "$project_path" ]]; then + show_error "Could not find project path for: $selection" + exit 1 + fi + + # Verify directory still exists + if [[ ! -d "$project_path" ]]; then + show_error "Project directory no longer exists: $project_path" + exit 1 + fi + + # Build terminal command + if [[ -n "$TERMINAL_CMD" ]]; then + # Check if %s placeholder is present + if [[ "$TERMINAL_CMD" == *"%s"* ]]; then + # Replace %s with project path and use as-is + final_cmd="''${TERMINAL_CMD//%s/$project_path}" + # shellcheck disable=SC2086 + exec "$TERMINAL" $final_cmd + else + # Treat as command to run: wrap in shell with cd (--hold keeps terminal open) + # Source /etc/profile (base PATH) and HM session vars (sessionPath) for NixOS + exec "$TERMINAL" --hold -e bash --login -c " + unset __HM_SESS_VARS_SOURCED + for f in ~/.nix-profile/etc/profile.d/hm-session-vars.sh /etc/profiles/per-user/\$USER/etc/profile.d/hm-session-vars.sh; do + [ -f \"\$f\" ] && . \"\$f\" + done + cd '$project_path' && exec $TERMINAL_CMD + " + fi + else + # Default: open terminal, cd to project, run opencode + # Source /etc/profile (base PATH) and HM session vars (sessionPath) for NixOS + exec "$TERMINAL" --hold -e bash --login -c " + unset __HM_SESS_VARS_SOURCED + for f in ~/.nix-profile/etc/profile.d/hm-session-vars.sh /etc/profiles/per-user/\$USER/etc/profile.d/hm-session-vars.sh; do + [ -f \"\$f\" ] && . \"\$f\" + done + cd '$project_path' && opencode + " + fi +''