diff --git a/README.md b/README.md index f137ad8b..0f7ea02e 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,10 @@ Automatic restoring and continuous saving of tmux env is also possible with - `prefix + Ctrl-s` - save - `prefix + Ctrl-r` - restore +On tmux `3.2+`, these bindings open a popup by default so save/restore progress +can stream directly in the popup with verbose logs. On older tmux versions, the +plugin falls back to the status-line messages automatically. + ### About This plugin goes to great lengths to save and restore all the details from your @@ -90,6 +94,11 @@ You should now be able to use the plugin. **Configuration** - [Changing the default key bindings](docs/custom_key_bindings.md). +- Popup UI options: + `@resurrect-popup 'auto'|'on'|'off'`, + `@resurrect-popup-width`, + `@resurrect-popup-height`, + `@resurrect-popup-close-delay`. - [Setting up hooks on save & restore](docs/hooks.md). - Only a conservative list of programs is restored by default:
`vi vim nvim emacs man less more tail top htop irssi weechat mutt`.
diff --git a/docs/custom_key_bindings.md b/docs/custom_key_bindings.md index 99bfc2c6..0cf1db4d 100644 --- a/docs/custom_key_bindings.md +++ b/docs/custom_key_bindings.md @@ -9,3 +9,15 @@ To change these, add to `.tmux.conf`: set -g @resurrect-save 'S' set -g @resurrect-restore 'R' + +Popup UI is enabled automatically on tmux `3.2+`. To force the old status-line +behavior instead, add: + + set -g @resurrect-popup 'off' + +To force popup mode or tune the popup size / close delay: + + set -g @resurrect-popup 'on' + set -g @resurrect-popup-width '70%' + set -g @resurrect-popup-height '70%' + set -g @resurrect-popup-close-delay '2' diff --git a/resurrect.tmux b/resurrect.tmux index 21fed7e1..c8d4d474 100755 --- a/resurrect.tmux +++ b/resurrect.tmux @@ -9,7 +9,7 @@ set_save_bindings() { local key_bindings=$(get_tmux_option "$save_option" "$default_save_key") local key for key in $key_bindings; do - tmux bind-key "$key" run-shell "$CURRENT_DIR/scripts/save.sh" + tmux bind-key "$key" run-shell "$CURRENT_DIR/scripts/run_with_popup.sh save" done } @@ -17,7 +17,7 @@ set_restore_bindings() { local key_bindings=$(get_tmux_option "$restore_option" "$default_restore_key") local key for key in $key_bindings; do - tmux bind-key "$key" run-shell "$CURRENT_DIR/scripts/restore.sh" + tmux bind-key "$key" run-shell "$CURRENT_DIR/scripts/run_with_popup.sh restore" done } diff --git a/scripts/check_tmux_version.sh b/scripts/check_tmux_version.sh index b0aedece..6bdd11ad 100755 --- a/scripts/check_tmux_version.sh +++ b/scripts/check_tmux_version.sh @@ -19,6 +19,11 @@ get_tmux_option() { display_message() { local message="$1" + if [ "${RESURRECT_OUTPUT_MODE:-}" = "popup" ]; then + printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$message" + return + fi + # display_duration defaults to 5 seconds, if not passed as an argument if [ "$#" -eq 2 ]; then local display_duration="$2" diff --git a/scripts/helpers.sh b/scripts/helpers.sh index 20d87dcd..26059e96 100644 --- a/scripts/helpers.sh +++ b/scripts/helpers.sh @@ -25,11 +25,105 @@ get_tmux_option() { fi } +get_digits_from_string() { + local string="$1" + local only_digits="$(echo "$string" | tr -dC '[:digit:]')" + echo "$only_digits" +} + +tmux_version_int() { + local tmux_version_string="$(tmux -V)" + echo "$(get_digits_from_string "$tmux_version_string")" +} + +tmux_version_at_least() { + local minimum_version="$1" + local current_version_int="$(tmux_version_int)" + local minimum_version_int="$(get_digits_from_string "$minimum_version")" + [ "$current_version_int" -ge "$minimum_version_int" ] +} + +tmux_popup_supported() { + tmux_version_at_least "3.2" +} + +should_use_popup() { + local popup_mode="$(get_tmux_option "$popup_mode_option" "$default_popup_mode")" + + case "$popup_mode" in + on|auto) + tmux_popup_supported + ;; + off) + return 1 + ;; + *) + return 1 + ;; + esac +} + +is_popup_output() { + [ "${SCRIPT_OUTPUT:-}" = "popup" ] +} + +popup_log() { + local message="$1" + + if is_popup_output; then + printf '%s\n' "$message" | ts '[%F %.T]' + fi +} + +popup_close_delay() { + get_tmux_option "$popup_close_delay_option" "$default_popup_close_delay" +} + +wait_for_popup_close() { + if is_popup_output; then + local delay="$(popup_close_delay)" + if [ -n "$delay" ] && [ "$delay" != "0" ]; then + sleep "$delay" + fi + fi +} + +shell_quote() { + printf '%q' "$1" +} + +popup_icon() { + local operation="$1" + + case "$operation" in + save) + printf "\\Uf10e9" + ;; + restore) + printf "\\Uf10ed" + ;; + esac +} + +popup_header() { + local operation="$1" + local label="$2" + + if is_popup_output; then + printf '%s %s\n' "$(popup_icon "$operation")" "$label" | ts '[%F %.T]' + fi +} + # Ensures a message is displayed for 5 seconds in tmux prompt. # Does not override the 'display-time' tmux option. display_message() { local message="$1" + if is_popup_output; then + popup_log "$message" + return + fi + # display_duration defaults to 5 seconds, if not passed as an argument if [ "$#" -eq 2 ]; then local display_duration="$2" @@ -52,7 +146,7 @@ display_message() { supported_tmux_version_ok() { - $CURRENT_DIR/check_tmux_version.sh "$SUPPORTED_VERSION" + RESURRECT_OUTPUT_MODE="${SCRIPT_OUTPUT:-}" "$CURRENT_DIR/check_tmux_version.sh" "$SUPPORTED_VERSION" } remove_first_char() { diff --git a/scripts/restore.sh b/scripts/restore.sh index 1a5e3f98..a3a14b89 100755 --- a/scripts/restore.sh +++ b/scripts/restore.sh @@ -249,6 +249,7 @@ detect_if_restoring_from_scratch() { local total_number_of_panes="$(tmux list-panes -a | wc -l | sed 's/ //g')" if [ "$total_number_of_panes" -eq 1 ]; then restore_from_scratch_true + popup_log "Only one pane is currently open; restore will reuse it" fi } @@ -256,6 +257,7 @@ detect_if_restoring_pane_contents() { if capture_pane_contents_option_on; then cache_tmux_default_command restore_pane_contents_true + popup_log "Restoring saved pane contents" fi } @@ -265,6 +267,7 @@ restore_all_panes() { detect_if_restoring_from_scratch # sets a global variable detect_if_restoring_pane_contents # sets a global variable if is_restoring_pane_contents; then + popup_log "Extracting archived pane contents" pane_content_files_restore_from_archive fi while read line; do @@ -364,24 +367,48 @@ cleanup_restored_pane_contents() { } main() { - if supported_tmux_version_ok && check_saved_session_exists; then - start_spinner "Restoring..." "Tmux restore complete!" - execute_hook "pre-restore-all" - restore_all_panes - handle_session_0 - restore_window_properties >/dev/null 2>&1 - execute_hook "pre-restore-pane-processes" - restore_all_pane_processes - # below functions restore exact cursor positions - restore_active_pane_for_each_window - restore_zoomed_windows - restore_grouped_sessions # also restores active and alt windows for grouped sessions - restore_active_and_alternate_windows - restore_active_and_alternate_sessions - cleanup_restored_pane_contents - execute_hook "post-restore-all" + if ! supported_tmux_version_ok; then + wait_for_popup_close + return 1 + fi + + if ! check_saved_session_exists; then + wait_for_popup_close + return 1 + fi + + if [ "${SCRIPT_OUTPUT:-}" != "quiet" ]; then + popup_header "restore" "loading..." + start_spinner "restoring..." "Tmux restore complete!" + fi + popup_log "Loading restore data from $(last_resurrect_file)" + + popup_log "Running pre-restore-all hook" + execute_hook "pre-restore-all" + popup_log "Restoring panes and sessions" + restore_all_panes + handle_session_0 + popup_log "Restoring window layouts and names" + restore_window_properties >/dev/null 2>&1 + popup_log "Running pre-restore-pane-processes hook" + execute_hook "pre-restore-pane-processes" + popup_log "Restoring pane processes" + restore_all_pane_processes + popup_log "Restoring active panes, zoom state, grouped sessions, and client state" + # below functions restore exact cursor positions + restore_active_pane_for_each_window + restore_zoomed_windows + restore_grouped_sessions # also restores active and alt windows for grouped sessions + restore_active_and_alternate_windows + restore_active_and_alternate_sessions + popup_log "Cleaning temporary restore artifacts" + cleanup_restored_pane_contents + popup_log "Running post-restore-all hook" + execute_hook "post-restore-all" + if [ "${SCRIPT_OUTPUT:-}" != "quiet" ]; then stop_spinner display_message "Tmux restore complete!" fi + wait_for_popup_close } main diff --git a/scripts/run_with_popup.sh b/scripts/run_with_popup.sh new file mode 100755 index 00000000..a32ee027 --- /dev/null +++ b/scripts/run_with_popup.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +CURRENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +source "$CURRENT_DIR/variables.sh" +source "$CURRENT_DIR/helpers.sh" + +action="$1" +script_path="" +title="" + +case "$action" in + save) + script_path="$CURRENT_DIR/save.sh" + title="tmux-resurrect save" + ;; + restore) + script_path="$CURRENT_DIR/restore.sh" + title="tmux-resurrect restore" + ;; + *) + exit 1 + ;; +esac + +run_command() { + "$script_path" +} + +run_popup() { + local popup_width="$(get_tmux_option "$popup_width_option" "$default_popup_width")" + local popup_height="$(get_tmux_option "$popup_height_option" "$default_popup_height")" + local popup_command="$(shell_quote "$script_path") popup" + + tmux display-popup -T "$title" -w "$popup_width" -h "$popup_height" -E "$popup_command" +} + +main() { + if should_use_popup; then + run_popup + else + run_command + fi +} +main diff --git a/scripts/save.sh b/scripts/save.sh index 01edcdeb..3dbfbcb8 100755 --- a/scripts/save.sh +++ b/scripts/save.sh @@ -12,6 +12,7 @@ delimiter=$'\t' # if "quiet" script produces no output SCRIPT_OUTPUT="$1" +SAVE_RESULT_DETAIL="" grouped_sessions_format() { local format @@ -238,41 +239,64 @@ remove_old_backups() { save_all() { local resurrect_file_path="$(resurrect_file_path)" local last_resurrect_file="$(last_resurrect_file)" + popup_log "Writing tmux snapshot to $resurrect_file_path" mkdir -p "$(resurrect_dir)" + popup_log "dumping grouped sessions..." fetch_and_dump_grouped_sessions > "$resurrect_file_path" + popup_log "dumping panes..." dump_panes >> "$resurrect_file_path" + popup_log "dumping windows..." dump_windows >> "$resurrect_file_path" + popup_log "dumping state..." dump_state >> "$resurrect_file_path" + popup_log "running post-save-layout hook..." execute_hook "post-save-layout" "$resurrect_file_path" if files_differ "$resurrect_file_path" "$last_resurrect_file"; then ln -fs "$(basename "$resurrect_file_path")" "$last_resurrect_file" + SAVE_RESULT_DETAIL="Save written to $resurrect_file_path" else rm "$resurrect_file_path" + SAVE_RESULT_DETAIL="No changes detected; existing save was kept" fi if capture_pane_contents_option_on; then + popup_log "Capturing pane contents" mkdir -p "$(pane_contents_dir "save")" dump_pane_contents pane_contents_create_archive rm "$(pane_contents_dir "save")"/* fi + popup_log "Pruning expired backup files" remove_old_backups + popup_log "Running post-save-all hook" execute_hook "post-save-all" } -show_output() { - [ "$SCRIPT_OUTPUT" != "quiet" ] +is_quiet() { + [ "$SCRIPT_OUTPUT" = "quiet" ] +} + +show_any_output() { + ! is_quiet } main() { - if supported_tmux_version_ok; then - if show_output; then - start_spinner "Saving..." "Tmux environment saved!" - fi - save_all - if show_output; then - stop_spinner - display_message "Tmux environment saved!" - fi + if ! supported_tmux_version_ok; then + wait_for_popup_close + return 1 + fi + + if show_any_output; then + popup_header "save" "saving..." + start_spinner "saving..." "Tmux environment saved!" + fi + save_all + if is_popup_output && [ -n "$SAVE_RESULT_DETAIL" ]; then + popup_log "$SAVE_RESULT_DETAIL" + fi + if show_any_output; then + stop_spinner + display_message "Tmux environment saved!" fi + wait_for_popup_close } main diff --git a/scripts/spinner_helpers.sh b/scripts/spinner_helpers.sh index fe73cd70..ae46f8b6 100644 --- a/scripts/spinner_helpers.sh +++ b/scripts/spinner_helpers.sh @@ -1,8 +1,93 @@ start_spinner() { + if is_popup_output; then + return + fi $CURRENT_DIR/tmux_spinner.sh "$1" "$2" & export SPINNER_PID=$! } stop_spinner() { + if is_popup_output; then + return + fi kill $SPINNER_PID } + +spinner_wave_label() { + local message="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" + + case "$message" in + "saving..."|"restoring...") + echo "$message" + ;; + *) + echo "" + ;; + esac +} + +spinner_wave_message() { + local message="$1" + local frame="$2" + local rendered="" + local message_length="${#message}" + local index=0 + local char="" + + if [ -z "$message" ]; then + return + fi + + while [ "$index" -lt "${#message}" ]; do + char="${message:$index:1}" + if [ "$char" = " " ]; then + rendered="${rendered}${char}" + else + local color="$(spinner_wave_color "$frame" "$index" "$message_length")" + rendered="${rendered}#[fg=colour${color}]${char}" + fi + index=$((index + 1)) + done + + printf '%s#[default]' "$rendered" +} + +spinner_wave_color() { + local frame="$1" + local index="$2" + local message_length="$3" + local min_color=240 + local max_color=253 + local gradient_width=5 + local pause_frames=10 + local half_width=$((gradient_width / 2)) + local pass_frames=$((message_length + gradient_width)) + local cycle_frames=$((pass_frames + pause_frames)) + local active_frame=0 + local center=0 + local distance=0 + + if [ -z "$message_length" ] || [ "$message_length" -le 0 ]; then + echo "$max_color" + return + fi + + active_frame=$((frame % cycle_frames)) + if [ "$active_frame" -ge "$pass_frames" ]; then + echo "$max_color" + return + fi + + center=$((active_frame - half_width)) + distance=$((index - center)) + if [ "$distance" -lt 0 ]; then + distance=$(( -distance )) + fi + + if [ "$distance" -gt "$half_width" ]; then + echo "$max_color" + return + fi + + echo $((min_color + (distance * (max_color - min_color) / half_width))) +} diff --git a/scripts/tmux_spinner.sh b/scripts/tmux_spinner.sh index 9b1b9792..a4e06919 100755 --- a/scripts/tmux_spinner.sh +++ b/scripts/tmux_spinner.sh @@ -12,17 +12,133 @@ # .. # kill $SPINNER_PID # Stops spinner and displays 'End message!' +CURRENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +source "$CURRENT_DIR/helpers.sh" +source "$CURRENT_DIR/spinner_helpers.sh" +source "$CURRENT_DIR/variables.sh" + MESSAGE="$1" END_MESSAGE="$2" -SPIN='-\|/' +DEFAULT_SPIN_CHARS='-\|/' +SPIN_CHARS=$( get_tmux_option "$spinner_chars_option" "$DEFAULT_SPIN_CHARS" ) +SPIN_CHARS_LENGTH=$( echo -n "$SPIN_CHARS" | wc -m ) +WAVE_LABEL="$(spinner_wave_label "$MESSAGE")" +CONTROL_MODE_FIFO="" +CONTROL_MODE_DIR="" +CONTROL_MODE_PID="" +CONTROL_MODE_READY="false" +TARGET_CLIENT_TTY="" + +tmux_quote() { + printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" +} + +tmux_control_target_client() { + tmux display-message -p -F "#{client_tty}" 2>/dev/null +} + +tmux_control_start() { + TARGET_CLIENT_TTY="$(tmux_control_target_client)" + + if [ -z "$TARGET_CLIENT_TTY" ]; then + return 1 + fi + + CONTROL_MODE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/tmux-resurrect-spinner.XXXXXX" 2>/dev/null)" + if [ -z "$CONTROL_MODE_DIR" ]; then + return 1 + fi + + CONTROL_MODE_FIFO="$CONTROL_MODE_DIR/control-mode" + if ! mkfifo "$CONTROL_MODE_FIFO"; then + rmdir "$CONTROL_MODE_DIR" + CONTROL_MODE_DIR="" + return 1 + fi + + tmux -C <"$CONTROL_MODE_FIFO" >/dev/null 2>&1 & + CONTROL_MODE_PID="$!" + + if ! exec 3>"$CONTROL_MODE_FIFO"; then + kill "$CONTROL_MODE_PID" 2>/dev/null + wait "$CONTROL_MODE_PID" 2>/dev/null + rm -f "$CONTROL_MODE_FIFO" + rmdir "$CONTROL_MODE_DIR" + CONTROL_MODE_FIFO="" + CONTROL_MODE_DIR="" + CONTROL_MODE_PID="" + return 1 + fi + + CONTROL_MODE_READY="true" +} + +tmux_control_send() { + local command="$1" + + if [ "$CONTROL_MODE_READY" = "true" ] && [ -n "$CONTROL_MODE_PID" ]; then + printf '%s\n' "$command" >&3 2>/dev/null || return 1 + return 0 + fi + + return 1 +} + +tmux_send_display_message() { + local message="$1" + local quoted_message="$(tmux_quote "$message")" + + if tmux_control_send "display-message -c $(tmux_quote "$TARGET_CLIENT_TTY") $quoted_message"; then + return 0 + fi + + tmux display-message "$message" +} + +cleanup() { + local final_message="$1" + + if [ -n "$final_message" ]; then + tmux_send_display_message "$final_message" + fi + + if [ "$CONTROL_MODE_READY" = "true" ]; then + exec 3>&- + CONTROL_MODE_READY="false" + fi + + if [ -n "$CONTROL_MODE_PID" ]; then + wait "$CONTROL_MODE_PID" 2>/dev/null + fi + + if [ -n "$CONTROL_MODE_FIFO" ]; then + rm -f "$CONTROL_MODE_FIFO" + fi + + if [ -n "$CONTROL_MODE_DIR" ]; then + rmdir "$CONTROL_MODE_DIR" 2>/dev/null + fi +} + +handle_exit() { + cleanup "$END_MESSAGE" + exit +} -trap "tmux display-message '$END_MESSAGE'; exit" SIGINT SIGTERM +trap "handle_exit" SIGINT SIGTERM main() { local i=0 + tmux_control_start while true; do - i=$(( (i+1) %4 )) - tmux display-message " ${SPIN:$i:1} $MESSAGE" + if [ -n "$WAVE_LABEL" ]; then + tmux_send_display_message "$(spinner_wave_message "$WAVE_LABEL" "$i")" + i=$((i + 1)) + else + i=$(( (i+1) % $SPIN_CHARS_LENGTH )) + tmux_send_display_message " ${SPIN_CHARS:$i:1} $MESSAGE" + fi sleep 0.1 done } diff --git a/scripts/variables.sh b/scripts/variables.sh index 9d42e02a..df1f03be 100644 --- a/scripts/variables.sh +++ b/scripts/variables.sh @@ -46,3 +46,14 @@ hook_prefix="@resurrect-hook-" delete_backup_after_option="@resurrect-delete-backup-after" default_delete_backup_after="30" # days + +spinner_chars_option="@resurrect-spinner-chars" + +popup_mode_option="@resurrect-popup" +default_popup_mode="auto" +popup_height_option="@resurrect-popup-height" +default_popup_height="80%" +popup_width_option="@resurrect-popup-width" +default_popup_width="80%" +popup_close_delay_option="@resurrect-popup-close-delay" +default_popup_close_delay="1"