Created
February 19, 2026 07:03
-
-
Save simoninglis/75cb6ac31fce48eca0fe684cad641b48 to your computer and use it in GitHub Desktop.
workon/workoff: project-specific tmux sessions with fzf, tab completion, and per-project .tmux-session files
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # Source: bash stow module (bash/.bashrc.d/30-project-workflow.sh) | |
| # Purpose: Tmux-based project workflow functions (workon/workoff) | |
| # Note: Sourced by ~/.bashrc, not executed directly | |
| # Project workflow functions for tmux session management | |
| # Helper function to normalize project names into valid tmux session names | |
| # Converts customer/project -> customer-project, scratch/proj -> scratch-proj | |
| function _normalize_session_name() { | |
| local project="$1" | |
| echo "${project//\//-}" # Replace all / with - | |
| } | |
| # Helper function to resolve project name to script path | |
| # Returns the full path to .tmux-session script for a given project | |
| function _get_project_script_path() { | |
| local project="$1" | |
| # Set default project directories if not configured | |
| local search_dirs="${PROJECT_DIRS:-$HOME/work:$HOME/scratch:$HOME}" | |
| IFS=':' read -ra dirs <<< "$search_dirs" | |
| for base_dir in "${dirs[@]}"; do | |
| # Expand tilde if present (needed for "~/path" in PROJECT_DIRS) | |
| base_dir="${base_dir/#\~/$HOME}" | |
| [[ ! -d "$base_dir" ]] && continue | |
| # For home directory, check specific projects | |
| if [[ "$base_dir" == "$HOME" ]]; then | |
| if [[ "$project" == "ansible-wsl" || "$project" == "dotfiles" ]]; then | |
| local script="$HOME/$project/.tmux-session" | |
| [[ -f "$script" ]] && echo "$script" && return 0 | |
| fi | |
| continue | |
| fi | |
| local base_name=$(basename "$base_dir") | |
| # Handle prefixed projects (scratch/foo, custom/bar) | |
| if [[ "$project" == "$base_name"/* ]]; then | |
| local stripped="${project#$base_name/}" | |
| local script="$base_dir/$stripped/.tmux-session" | |
| [[ -f "$script" ]] && echo "$script" && return 0 | |
| fi | |
| # Handle customer/project format (nested) | |
| if [[ "$project" == */* ]]; then | |
| local script="$base_dir/$project/.tmux-session" | |
| [[ -f "$script" ]] && echo "$script" && return 0 | |
| fi | |
| # Handle simple project name in this base directory | |
| local script="$base_dir/$project/.tmux-session" | |
| [[ -f "$script" ]] && echo "$script" && return 0 | |
| # Search within customer/nested directories for matching project | |
| # This handles: workon homelab-netbox -> ~/work/homelab/homelab-netbox | |
| for customer_dir in "$base_dir"/*; do | |
| if [[ -d "$customer_dir" ]]; then | |
| local nested_script="$customer_dir/$project/.tmux-session" | |
| [[ -f "$nested_script" ]] && echo "$nested_script" && return 0 | |
| fi | |
| done | |
| done | |
| return 1 | |
| } | |
| # Helper function to read from project cache | |
| # Returns 0 and outputs project names if cache is valid, 1 if cache miss/stale | |
| function _read_project_cache() { | |
| local cache_file="$HOME/.cache/work-projects.json" | |
| local ttl=300 # 5 minutes (must match update-project-cache TTL) | |
| # Check if cache exists | |
| [[ ! -f "$cache_file" ]] && return 1 | |
| # Check if jq is available | |
| command -v jq &>/dev/null || return 1 | |
| # Read cache metadata | |
| local updated | |
| updated=$(jq -r '.updated // empty' "$cache_file" 2>/dev/null) | |
| [[ -z "$updated" ]] && return 1 | |
| # Check TTL - convert ISO timestamp to epoch | |
| local cache_time now | |
| cache_time=$(date -d "$updated" +%s 2>/dev/null) || return 1 | |
| now=$(date +%s) | |
| if (( now - cache_time >= ttl )); then | |
| return 1 # Cache is stale | |
| fi | |
| # Cache is valid - output project names | |
| jq -r '.projects[].name' "$cache_file" 2>/dev/null | |
| return 0 | |
| } | |
| # Helper function to get all available projects | |
| # Uses cache with read-through pattern, falls back to filesystem scan | |
| # Default: searches ~/work, ~/scratch, and home directory | |
| function _get_work_projects() { | |
| # Try to read from cache first (fast path) | |
| if _read_project_cache; then | |
| return 0 | |
| fi | |
| # Cache miss - trigger background update (non-blocking) | |
| if [[ -x "$HOME/.local/bin/update-project-cache" ]]; then | |
| "$HOME/.local/bin/update-project-cache" &>/dev/null & | |
| disown 2>/dev/null | |
| fi | |
| # Fall back to filesystem scan | |
| _scan_work_projects_filesystem | |
| } | |
| # Filesystem scan for projects (fallback when cache is unavailable) | |
| function _scan_work_projects_filesystem() { | |
| # Set default project directories if not configured | |
| local search_dirs="${PROJECT_DIRS:-$HOME/work:$HOME/scratch:$HOME}" | |
| local projects=() | |
| # Split PROJECT_DIRS by colon and search each directory | |
| IFS=':' read -ra dirs <<< "$search_dirs" | |
| for base_dir in "${dirs[@]}"; do | |
| # Expand tilde if present (needed for "~/path" in PROJECT_DIRS) | |
| base_dir="${base_dir/#\~/$HOME}" | |
| [[ ! -d "$base_dir" ]] && continue | |
| # For home directory, only check specific known projects | |
| if [[ "$base_dir" == "$HOME" ]]; then | |
| [[ -f "$HOME/ansible-wsl/.tmux-session" ]] && projects+=("ansible-wsl") | |
| [[ -f "$HOME/dotfiles/.tmux-session" ]] && projects+=("dotfiles") | |
| continue | |
| fi | |
| # Determine base name for path construction | |
| local base_name=$(basename "$base_dir") | |
| # Find all .tmux-session files in this directory tree | |
| for dir in "$base_dir"/*; do | |
| [[ ! -d "$dir" ]] && continue | |
| # Direct project (has .tmux-session in immediate subdirectory) | |
| if [[ -f "$dir/.tmux-session" ]]; then | |
| local project_name=$(basename "$dir") | |
| # Add prefix for non-work directories | |
| if [[ "$base_name" != "work" ]]; then | |
| projects+=("${base_name}/${project_name}") | |
| else | |
| projects+=("${project_name}") | |
| fi | |
| fi | |
| # Customer/nested projects (subdirectories with .tmux-session) | |
| # Only search one level deep to avoid performance issues | |
| for subdir in "$dir"/*; do | |
| if [[ -d "$subdir" && -f "$subdir/.tmux-session" ]]; then | |
| local customer=$(basename "$dir") | |
| local project=$(basename "$subdir") | |
| # Add base prefix for non-work directories to avoid ambiguity | |
| if [[ "$base_name" != "work" ]]; then | |
| projects+=("${base_name}/${customer}/${project}") | |
| else | |
| projects+=("${customer}/${project}") | |
| fi | |
| fi | |
| done | |
| done | |
| done | |
| # Sort and output unique projects | |
| printf '%s\n' "${projects[@]}" | sort | uniq | |
| } | |
| # Function to work on a project by running its .tmux-session script | |
| function workon() { | |
| local project=$1 | |
| # If no project specified and fzf is available, use fuzzy search | |
| if [[ -z "$project" ]] && command -v fzf >/dev/null 2>&1; then | |
| project=$(_get_work_projects | fzf --prompt="Select project: " --height=10 --layout=reverse) | |
| [[ -z "$project" ]] && return 0 # User cancelled | |
| fi | |
| # Use centralized path resolution | |
| local script=$(_get_project_script_path "$project") | |
| if [[ -n "$script" ]] && [[ -x "$script" ]]; then | |
| # Set terminal title to project name | |
| if [[ "$project" == */* ]]; then | |
| # Prefixed format (scratch/proj, customer/project) | |
| set_terminal_title_manual "[${project/\// : }]" | |
| else | |
| # Naked project | |
| set_terminal_title_manual "[$project]" | |
| fi | |
| # Export normalized session name for .tmux-session to use | |
| export TMUX_SESSION_NAME="$(_normalize_session_name "$project")" | |
| export PROJECT_NAME="$project" | |
| "$script" | |
| else | |
| echo "No .tmux-session script found for $project" | |
| [[ -n "$script" ]] && echo "Found but not executable: $script" | |
| fi | |
| } | |
| # Pause work on a project (switch away without killing session) | |
| function workpause() { | |
| local project=$1 | |
| # Only works from within tmux | |
| if [[ -z "$TMUX" ]]; then | |
| echo "workpause: must be run from within tmux" | |
| return 1 | |
| fi | |
| # If no project specified, use current session | |
| if [[ -z "$project" ]]; then | |
| project=$(tmux display-message -p '#S') | |
| fi | |
| # Normalize session name (customer/project -> customer-project) | |
| local session_name="$(_normalize_session_name "$project")" | |
| # Verify the target session exists | |
| if ! tmux has-session -t "=${session_name}" 2>/dev/null; then | |
| echo "Session '$project' not found" | |
| return 1 | |
| fi | |
| # Get current session | |
| local current_session=$(tmux display-message -p '#S') | |
| # Check if we're in the target session | |
| if [[ "$current_session" == "$session_name" ]]; then | |
| # Pausing current session - need to switch away | |
| # Try per-client last session first (prevents multi-client MRU hijacking) | |
| if tmux switch-client -l 2>/dev/null; then | |
| local switched_to=$(tmux display-message -p '#S') | |
| echo "✔ paused $project, switched to $switched_to" | |
| return 0 | |
| fi | |
| # Fallback: Find another session to switch to (global MRU) | |
| local fallback=$(tmux list-sessions -F '#{session_last_attached} #{session_name}' 2>/dev/null | sort -rn | awk '{print $2}' | grep -Fxv "$session_name" | head -n1) | |
| if [[ -n "$fallback" ]]; then | |
| tmux switch-client -t "=${fallback}" | |
| echo "✔ paused $project, switched to $fallback" | |
| else | |
| # No other sessions available | |
| # Check if we're in SSH - detaching would orphan the shell | |
| if [[ -n "$SSH_TTY" ]]; then | |
| # Create/switch to scratch session instead of detaching | |
| local scratch_session="scratch-ssh" | |
| if ! tmux has-session -t "=${scratch_session}" 2>/dev/null; then | |
| tmux new-session -d -s "$scratch_session" -c "$HOME" | |
| fi | |
| tmux switch-client -t "=${scratch_session}" | |
| echo "✔ paused $project, created scratch session (SSH detected)" | |
| else | |
| # Not in SSH, safe to detach | |
| tmux detach-client | |
| echo "✔ paused $project (detached, no other sessions)" | |
| fi | |
| fi | |
| else | |
| # Pausing a different session - just confirm it exists | |
| echo "✔ session $project remains active (not attached to it)" | |
| fi | |
| } | |
| # Helper to cleanly shut down all tmux sessions for a project | |
| function workoff() { | |
| local force=false | |
| local project="" | |
| # Parse flags | |
| while [[ $# -gt 0 ]]; do | |
| case $1 in | |
| --force|-f) | |
| force=true | |
| shift | |
| ;; | |
| *) | |
| project=$1 | |
| shift | |
| ;; | |
| esac | |
| done | |
| # If no project specified and fzf is available, use fuzzy search | |
| if [[ -z "$project" ]] && command -v fzf >/dev/null 2>&1; then | |
| # Get unique project prefixes from active tmux sessions | |
| local active_projects=$(tmux list-sessions -F '#S' 2>/dev/null | sort | uniq) | |
| if [[ -z "$active_projects" ]]; then | |
| echo "No active tmux sessions found." | |
| return 1 | |
| fi | |
| project=$(echo "$active_projects" | fzf --prompt="Select project to stop: " --height=10 --layout=reverse) | |
| [[ -z "$project" ]] && return 0 # User cancelled | |
| elif [[ -z $project ]]; then | |
| echo "Usage: workoff <project>" | |
| return 1 | |
| fi | |
| # Normalize session name (customer/project -> customer-project) | |
| local session_name="$(_normalize_session_name "$project")" | |
| # Graceful shutdown: if killing current session, switch away first | |
| if [[ -n "$TMUX" ]]; then | |
| local current_session=$(tmux display-message -p '#S' 2>/dev/null) | |
| if [[ "$current_session" == "$session_name" ]]; then | |
| # Try per-client last session first (prevents multi-client MRU hijacking) | |
| if tmux switch-client -l 2>/dev/null; then | |
| local switched_to=$(tmux display-message -p '#S') | |
| echo "✔ switched to $switched_to (will kill $project)" | |
| else | |
| # Fallback: Find another session to switch to (global MRU) | |
| local fallback=$(tmux list-sessions -F '#{session_last_attached} #{session_name}' 2>/dev/null | sort -rn | awk '{print $2}' | grep -Fxv "$current_session" | head -n1) | |
| if [[ -n "$fallback" ]]; then | |
| # Switch to fallback before killing | |
| tmux switch-client -t "=${fallback}" | |
| echo "✔ switched to $fallback (will kill $project)" | |
| else | |
| # Last session - warn user (unless --force or non-interactive) | |
| if [[ "$force" == true ]] || [[ ! -t 0 ]]; then | |
| # Force mode or non-interactive stdin: skip prompt, just kill | |
| echo "⚠ Killing last session (tmux will exit)" | |
| else | |
| # Interactive mode: prompt user | |
| echo "⚠ Last session - tmux will exit" | |
| read -p "Kill last session and exit tmux? [y/N] " -n 1 -r | |
| echo | |
| if [[ ! $REPLY =~ ^[Yy]$ ]]; then | |
| echo "Cancelled" | |
| return 0 | |
| fi | |
| fi | |
| fi | |
| fi | |
| fi | |
| fi | |
| # Kill the normalized session name | |
| if tmux has-session -t "=${session_name}" 2>/dev/null; then | |
| # Check if we're killing the current session (after potential switch) | |
| local killed_current=false | |
| if [[ -n "$TMUX" ]]; then | |
| local final_session=$(tmux display-message -p '#S' 2>/dev/null) | |
| # If we switched away earlier, current session != session_name | |
| # But if we're outside tmux after switch, reset title | |
| [[ "$final_session" != "$session_name" ]] && killed_current=false || killed_current=true | |
| fi | |
| tmux kill-session -t "=${session_name}" && echo "✔ killed $project" | |
| # Only reset terminal title if we killed our own session (and switched away) | |
| # If still in tmux, the hook will set the title for the new session | |
| if [[ -z "$TMUX" ]] || [[ "$killed_current" == false ]]; then | |
| # Either we exited tmux entirely, or we killed a different session | |
| # In the latter case, don't touch the title (we're still in our session) | |
| if [[ -z "$TMUX" ]]; then | |
| reset_terminal_title | |
| fi | |
| fi | |
| else | |
| echo "No tmux session found for '${project}' (looking for session: ${session_name})." | |
| return 1 | |
| fi | |
| } | |
| # Quick ad-hoc session - for temporary work (SSH, experiments, debugging) | |
| function quick-session() { | |
| local session_name=$1 | |
| if [[ -z "$session_name" ]]; then | |
| echo "Usage: quick-session <session-name>" | |
| echo "" | |
| echo "Create or switch to an ad-hoc tmux session (no .tmux-session file needed)." | |
| echo "Perfect for SSH sessions, experiments, or temporary debugging work." | |
| echo "" | |
| echo "Examples:" | |
| echo " quick-session ssh-server1" | |
| echo " quick-session experiment" | |
| echo " quick-session temp-debug" | |
| return 1 | |
| fi | |
| # Check if session already exists | |
| if tmux has-session -t "=${session_name}" 2>/dev/null; then | |
| # Session exists, just switch to it | |
| ~/.local/bin/tmux-join "=${session_name}" | |
| echo "✔ Switched to existing session: $session_name" | |
| else | |
| # Create new session | |
| if [[ -n "$TMUX" ]]; then | |
| # Inside tmux - create detached and switch | |
| tmux new-session -d -s "$session_name" | |
| tmux switch-client -t "=${session_name}" | |
| else | |
| # Outside tmux - create and attach | |
| tmux new-session -s "$session_name" | |
| fi | |
| echo "✔ Created quick session: $session_name" | |
| fi | |
| # Set terminal title | |
| if command -v tmux-set-title >/dev/null 2>&1; then | |
| ~/.local/bin/tmux-set-title "$session_name" | |
| fi | |
| } | |
| # Tab-completion for workon | |
| _work_projects_complete() { | |
| local cur=${COMP_WORDS[COMP_CWORD]} | |
| COMPREPLY=( $(compgen -W "$(_get_work_projects)" -- "$cur") ) | |
| } | |
| # Tab-completion for workoff (shows active sessions) | |
| _workoff_complete() { | |
| local cur=${COMP_WORDS[COMP_CWORD]} | |
| # Show both full session names and project names from _get_work_projects | |
| local active_sessions=$(tmux list-sessions -F '#S' 2>/dev/null | sort | uniq) | |
| local all_projects=$(_get_work_projects) | |
| local combined=$(echo -e "$active_sessions\n$all_projects" | sort | uniq) | |
| COMPREPLY=( $(compgen -W "$combined" -- "$cur") ) | |
| } | |
| # Tab-completion for workpause (shows only active sessions) | |
| _workpause_complete() { | |
| local cur=${COMP_WORDS[COMP_CWORD]} | |
| local active_sessions=$(tmux list-sessions -F '#S' 2>/dev/null | sort | uniq) | |
| COMPREPLY=( $(compgen -W "$active_sessions" -- "$cur") ) | |
| } | |
| # Tab-completion for quick-session (shows existing tmux sessions) | |
| _quick_session_complete() { | |
| local cur=${COMP_WORDS[COMP_CWORD]} | |
| local active_sessions=$(tmux list-sessions -F '#S' 2>/dev/null | sort | uniq) | |
| COMPREPLY=( $(compgen -W "$active_sessions" -- "$cur") ) | |
| } | |
| complete -F _work_projects_complete workon | |
| complete -F _workoff_complete workoff | |
| complete -F _workpause_complete workpause | |
| complete -F _quick_session_complete quick-session |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # Source: bin stow module (bin/.local/bin/tmux-join) | |
| # Purpose: Smart tmux session attach/switch - prevents nested tmux | |
| # Usage: tmux-join SESSION_TARGET | |
| # | |
| # When called from within tmux: switches to target session | |
| # When called from outside tmux: attaches to target session | |
| # This prevents nested tmux sessions while supporting both workflows | |
| set -euo pipefail | |
| if [[ $# -ne 1 ]]; then | |
| echo "Usage: tmux-join SESSION_TARGET" >&2 | |
| echo "Example: tmux-join '=dotfiles:0'" >&2 | |
| exit 1 | |
| fi | |
| SESSION_TARGET="$1" | |
| # Wait for session to be created (race condition protection) | |
| # This prevents errors when .tmux-session creates windows and immediately joins | |
| MAX_RETRIES=10 | |
| RETRY_DELAY=0.1 # 100ms | |
| for ((i=1; i<=MAX_RETRIES; i++)); do | |
| if tmux has-session -t "$SESSION_TARGET" 2>/dev/null; then | |
| # Session exists and is ready | |
| break | |
| fi | |
| if [[ $i -eq MAX_RETRIES ]]; then | |
| echo "Error: Session '$SESSION_TARGET' not found after ${MAX_RETRIES} retries" >&2 | |
| exit 1 | |
| fi | |
| sleep "$RETRY_DELAY" | |
| done | |
| # Try switch-client first (works when inside tmux) | |
| # Falls back to attach if not in tmux or switch fails | |
| tmux switch-client -t "$SESSION_TARGET" 2>/dev/null || tmux attach -t "$SESSION_TARGET" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment