Skip to content

Instantly share code, notes, and snippets.

@simoninglis
Created February 19, 2026 07:03
Show Gist options
  • Select an option

  • Save simoninglis/75cb6ac31fce48eca0fe684cad641b48 to your computer and use it in GitHub Desktop.

Select an option

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
#!/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
#!/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