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
+''