mirror of
https://github.com/tw93/Mole.git
synced 2026-02-06 06:42:56 +00:00
🎨 Continue to upgrade to easy to use
This commit is contained in:
@@ -1,39 +1,40 @@
|
||||
#!/bin/bash
|
||||
# App selection functionality
|
||||
|
||||
# App selection functionality using the new menu system
|
||||
# This replaces the complex interactive_app_selection function
|
||||
set -euo pipefail
|
||||
|
||||
# Interactive app selection using the menu.sh library
|
||||
# Format app info for display
|
||||
format_app_display() {
|
||||
local display_name="$1" size="$2" last_used="$3"
|
||||
|
||||
# Truncate long names
|
||||
local truncated_name="$display_name"
|
||||
if [[ ${#display_name} -gt 24 ]]; then
|
||||
truncated_name="${display_name:0:21}..."
|
||||
fi
|
||||
|
||||
# Format size
|
||||
local size_str="Unknown"
|
||||
[[ "$size" != "0" && "$size" != "" && "$size" != "Unknown" ]] && size_str="$size"
|
||||
|
||||
printf "%-24s (%s) | %s" "$truncated_name" "$size_str" "$last_used"
|
||||
}
|
||||
|
||||
# Global variable to store selection result (bash 3.2 compatible)
|
||||
MOLE_SELECTION_RESULT=""
|
||||
|
||||
# Main app selection function
|
||||
select_apps_for_uninstall() {
|
||||
if [[ ${#apps_data[@]} -eq 0 ]]; then
|
||||
log_warning "No applications available for uninstallation"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Build menu options from apps_data
|
||||
# Build menu options
|
||||
local -a menu_options=()
|
||||
for app_data in "${apps_data[@]}"; do
|
||||
IFS='|' read -r epoch app_path app_name bundle_id size last_used <<< "$app_data"
|
||||
|
||||
# The size is already formatted (e.g., "91M", "2.1G"), so use it directly
|
||||
local size_str="Unknown"
|
||||
if [[ "$size" != "0" && "$size" != "" && "$size" != "Unknown" ]]; then
|
||||
size_str="$size"
|
||||
fi
|
||||
|
||||
# Format display name with better width control
|
||||
local display_name
|
||||
local max_name_length=25
|
||||
local truncated_name="$app_name"
|
||||
|
||||
# Truncate app name if too long
|
||||
if [[ ${#app_name} -gt $max_name_length ]]; then
|
||||
truncated_name="${app_name:0:$((max_name_length-3))}..."
|
||||
fi
|
||||
|
||||
# Create aligned display format
|
||||
display_name=$(printf "%-${max_name_length}s %8s | %s" "$truncated_name" "($size_str)" "$last_used")
|
||||
menu_options+=("$display_name")
|
||||
IFS='|' read -r epoch app_path display_name bundle_id size last_used <<< "$app_data"
|
||||
menu_options+=("$(format_app_display "$display_name" "$size" "$last_used")")
|
||||
done
|
||||
|
||||
echo ""
|
||||
@@ -42,12 +43,9 @@ select_apps_for_uninstall() {
|
||||
echo "Found ${#apps_data[@]} apps. Select apps to remove:"
|
||||
echo ""
|
||||
|
||||
# Load paginated menu system (arrow key navigation)
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/paginated_menu.sh"
|
||||
|
||||
# Use paginated multi-select menu with arrow key navigation
|
||||
local selected_indices
|
||||
selected_indices=$(paginated_multi_select "Select Apps to Remove" "${menu_options[@]}")
|
||||
# Use paginated menu - result will be stored in MOLE_SELECTION_RESULT
|
||||
MOLE_SELECTION_RESULT=""
|
||||
paginated_multi_select "Select Apps to Remove" "${menu_options[@]}"
|
||||
local exit_code=$?
|
||||
|
||||
if [[ $exit_code -ne 0 ]]; then
|
||||
@@ -55,103 +53,30 @@ select_apps_for_uninstall() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -z "$selected_indices" ]]; then
|
||||
if [[ -z "$MOLE_SELECTION_RESULT" ]]; then
|
||||
echo "No apps selected"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Build selected_apps array from indices
|
||||
# Build selected apps array (global variable in bin/uninstall.sh)
|
||||
# Clear existing selections - compatible with bash 3.2
|
||||
selected_apps=()
|
||||
for idx in $selected_indices; do
|
||||
# Validate that idx is a number
|
||||
if [[ "$idx" =~ ^[0-9]+$ ]]; then
|
||||
|
||||
# Parse indices and build selected apps array
|
||||
# Convert space-separated string to array for better handling
|
||||
read -a indices_array <<< "$MOLE_SELECTION_RESULT"
|
||||
|
||||
for idx in "${indices_array[@]}"; do
|
||||
if [[ "$idx" =~ ^[0-9]+$ ]] && [[ $idx -ge 0 ]] && [[ $idx -lt ${#apps_data[@]} ]]; then
|
||||
selected_apps+=("${apps_data[idx]}")
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Selected ${#selected_apps[@]} apps"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Alternative simplified single-select interface for quick selection
|
||||
quick_select_app() {
|
||||
if [[ ${#apps_data[@]} -eq 0 ]]; then
|
||||
log_warning "No applications available for uninstallation"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Build menu options from apps_data (same as above)
|
||||
local -a menu_options=()
|
||||
for app_data in "${apps_data[@]}"; do
|
||||
IFS='|' read -r epoch app_path app_name bundle_id size last_used <<< "$app_data"
|
||||
|
||||
# The size is already formatted (e.g., "91M", "2.1G"), so use it directly
|
||||
local size_str="Unknown"
|
||||
if [[ "$size" != "0" && "$size" != "" && "$size" != "Unknown" ]]; then
|
||||
size_str="$size"
|
||||
fi
|
||||
|
||||
# Format display name with better width control
|
||||
local display_name
|
||||
local max_name_length=25
|
||||
local truncated_name="$app_name"
|
||||
|
||||
# Truncate app name if too long
|
||||
if [[ ${#app_name} -gt $max_name_length ]]; then
|
||||
truncated_name="${app_name:0:$((max_name_length-3))}..."
|
||||
fi
|
||||
|
||||
# Create aligned display format
|
||||
display_name=$(printf "%-${max_name_length}s %8s | %s" "$truncated_name" "($size_str)" "$last_used")
|
||||
menu_options+=("$display_name")
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "🗑️ Quick Uninstall"
|
||||
echo ""
|
||||
|
||||
# Use single-select menu
|
||||
if show_menu "Quick Uninstall" "${menu_options[@]}"; then
|
||||
local selected_idx=$?
|
||||
selected_apps=("${apps_data[selected_idx]}")
|
||||
echo "✅ Selected: ${menu_options[selected_idx]}"
|
||||
return 0
|
||||
else
|
||||
echo "❌ Operation cancelled"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Show app selection mode menu
|
||||
show_app_selection_mode() {
|
||||
echo ""
|
||||
echo "🗑️ Application Uninstaller"
|
||||
echo ""
|
||||
|
||||
local mode_options=(
|
||||
"Batch Mode (select multiple apps with checkboxes)"
|
||||
"Quick Mode (select one app at a time)"
|
||||
"Exit Uninstaller"
|
||||
)
|
||||
|
||||
if show_menu "Choose uninstall mode:" "${mode_options[@]}"; then
|
||||
local mode=$?
|
||||
case $mode in
|
||||
0)
|
||||
select_apps_for_uninstall
|
||||
return $?
|
||||
;;
|
||||
1)
|
||||
quick_select_app
|
||||
return $?
|
||||
;;
|
||||
2)
|
||||
echo "Goodbye!"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
else
|
||||
echo "Operation cancelled"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
# Export function for external use
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
echo "This is a library file. Source it from other scripts." >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -2,55 +2,7 @@
|
||||
|
||||
# Batch uninstall functionality with minimal confirmations
|
||||
# Replaces the overly verbose individual confirmation approach
|
||||
|
||||
# Find and list app-related files
|
||||
find_app_files() {
|
||||
local bundle_id="$1"
|
||||
local app_name="$2"
|
||||
local -a files_to_clean=()
|
||||
|
||||
# Application Support
|
||||
[[ -d ~/Library/Application\ Support/"$app_name" ]] && files_to_clean+=("$HOME/Library/Application Support/$app_name")
|
||||
[[ -d ~/Library/Application\ Support/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Application Support/$bundle_id")
|
||||
|
||||
# Caches
|
||||
[[ -d ~/Library/Caches/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Caches/$bundle_id")
|
||||
|
||||
# Preferences
|
||||
[[ -f ~/Library/Preferences/"$bundle_id".plist ]] && files_to_clean+=("$HOME/Library/Preferences/$bundle_id.plist")
|
||||
|
||||
# Logs
|
||||
[[ -d ~/Library/Logs/"$app_name" ]] && files_to_clean+=("$HOME/Library/Logs/$app_name")
|
||||
[[ -d ~/Library/Logs/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Logs/$bundle_id")
|
||||
|
||||
# Saved Application State
|
||||
[[ -d ~/Library/Saved\ Application\ State/"$bundle_id".savedState ]] && files_to_clean+=("$HOME/Library/Saved Application State/$bundle_id.savedState")
|
||||
|
||||
# Containers (sandboxed apps)
|
||||
[[ -d ~/Library/Containers/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Containers/$bundle_id")
|
||||
|
||||
# Group Containers
|
||||
while IFS= read -r -d '' container; do
|
||||
files_to_clean+=("$container")
|
||||
done < <(find ~/Library/Group\ Containers -name "*$bundle_id*" -type d -print0 2>/dev/null)
|
||||
|
||||
printf '%s\n' "${files_to_clean[@]}"
|
||||
}
|
||||
|
||||
# Calculate total size of files
|
||||
calculate_total_size() {
|
||||
local files="$1"
|
||||
local total_kb=0
|
||||
|
||||
while IFS= read -r file; do
|
||||
if [[ -n "$file" && -e "$file" ]]; then
|
||||
local size_kb=$(du -sk "$file" 2>/dev/null | awk '{print $1}' || echo "0")
|
||||
((total_kb += size_kb))
|
||||
fi
|
||||
done <<< "$files"
|
||||
|
||||
echo "$total_kb"
|
||||
}
|
||||
# Note: find_app_files() and calculate_total_size() functions now in lib/common.sh
|
||||
|
||||
# Batch uninstall with single confirmation
|
||||
batch_uninstall_applications() {
|
||||
@@ -83,14 +35,12 @@ batch_uninstall_applications() {
|
||||
((total_estimated_size += total_kb))
|
||||
|
||||
# Store details for later use
|
||||
app_details+=("$app_name|$app_path|$bundle_id|$total_kb|$related_files")
|
||||
# Base64 encode related_files to handle multi-line data safely
|
||||
local encoded_files=$(echo "$related_files" | base64)
|
||||
app_details+=("$app_name|$app_path|$bundle_id|$total_kb|$encoded_files")
|
||||
done
|
||||
|
||||
# Show summary and get batch confirmation
|
||||
echo ""
|
||||
echo "📊 Uninstallation Summary:"
|
||||
echo " • Applications to remove: ${#selected_apps[@]}"
|
||||
|
||||
# Format size display
|
||||
if [[ $total_estimated_size -gt 1048576 ]]; then
|
||||
local size_display=$(echo "$total_estimated_size" | awk '{printf "%.2fGB", $1/1024/1024}')
|
||||
elif [[ $total_estimated_size -gt 1024 ]]; then
|
||||
@@ -98,31 +48,26 @@ batch_uninstall_applications() {
|
||||
else
|
||||
local size_display="${total_estimated_size}KB"
|
||||
fi
|
||||
echo " • Estimated space to free: $size_display"
|
||||
|
||||
# Show summary and get batch confirmation
|
||||
echo ""
|
||||
echo "Will remove ${#selected_apps[@]} applications, free $size_display"
|
||||
if [[ ${#running_apps[@]} -gt 0 ]]; then
|
||||
echo " • ⚠️ Running apps that will be force-quit:"
|
||||
for app in "${running_apps[@]}"; do
|
||||
echo " - $app"
|
||||
done
|
||||
echo "Running apps will be force-quit: ${running_apps[*]}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Selected applications:"
|
||||
for selected_app in "${selected_apps[@]}"; do
|
||||
IFS='|' read -r epoch app_path app_name bundle_id size last_used <<< "$selected_app"
|
||||
echo " • $app_name ($size)"
|
||||
done
|
||||
read -p "Press ENTER to confirm, or any other key to cancel: " -r
|
||||
|
||||
echo ""
|
||||
read -p "🗑️ Proceed with uninstalling ALL ${#selected_apps[@]} applications? This cannot be undone. (Y/n): " -n 1 -r
|
||||
echo
|
||||
|
||||
if [[ $REPLY =~ ^[Nn]$ ]]; then
|
||||
if [[ -n "$REPLY" ]]; then
|
||||
log_info "Uninstallation cancelled by user"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "⚡ Starting uninstallation in 3 seconds... (Press Ctrl+C to abort)"
|
||||
sleep 1 && echo "⚡ 2..."
|
||||
sleep 1 && echo "⚡ 1..."
|
||||
sleep 1
|
||||
|
||||
# Force quit running apps first (batch)
|
||||
if [[ ${#running_apps[@]} -gt 0 ]]; then
|
||||
echo ""
|
||||
@@ -142,9 +87,11 @@ batch_uninstall_applications() {
|
||||
local failed_count=0
|
||||
|
||||
for detail in "${app_details[@]}"; do
|
||||
IFS='|' read -r app_name app_path bundle_id total_kb related_files <<< "$detail"
|
||||
IFS='|' read -r app_name app_path bundle_id total_kb encoded_files <<< "$detail"
|
||||
|
||||
# Decode the related files list
|
||||
local related_files=$(echo "$encoded_files" | base64 -d)
|
||||
|
||||
echo ""
|
||||
echo "🗑️ Uninstalling: $app_name"
|
||||
|
||||
# Remove the application
|
||||
@@ -178,20 +125,27 @@ batch_uninstall_applications() {
|
||||
|
||||
# Show final summary
|
||||
echo ""
|
||||
log_header "Uninstallation Complete"
|
||||
|
||||
echo "===================================================================="
|
||||
echo "🎉 UNINSTALLATION COMPLETE!"
|
||||
|
||||
if [[ $success_count -gt 0 ]]; then
|
||||
if [[ $total_size_freed -gt 1048576 ]]; then
|
||||
local freed_display=$(echo "$total_size_freed" | awk '{printf "%.2fGB", $1/1024/1024}')
|
||||
local freed_display=$(echo "$total_size_freed" | awk '{printf "%.1fGB", $1/1024/1024}')
|
||||
elif [[ $total_size_freed -gt 1024 ]]; then
|
||||
local freed_display=$(echo "$total_size_freed" | awk '{printf "%.1fMB", $1/1024}')
|
||||
else
|
||||
local freed_display="${total_size_freed}KB"
|
||||
fi
|
||||
log_success "Successfully uninstalled $success_count applications"
|
||||
log_success "Freed $freed_display of disk space"
|
||||
echo "🗑️ Apps uninstalled: $success_count | Space freed: $freed_display"
|
||||
else
|
||||
echo "🗑️ No applications were uninstalled"
|
||||
fi
|
||||
|
||||
|
||||
if [[ $failed_count -gt 0 ]]; then
|
||||
echo "⚠️ Failed to uninstall: $failed_count"
|
||||
fi
|
||||
|
||||
echo "===================================================================="
|
||||
if [[ $failed_count -gt 0 ]]; then
|
||||
log_warning "$failed_count applications failed to uninstall"
|
||||
fi
|
||||
|
||||
363
lib/common.sh
363
lib/common.sh
@@ -78,10 +78,19 @@ clear_screen() {
|
||||
printf '\033[2J\033[H'
|
||||
}
|
||||
|
||||
hide_cursor() {
|
||||
printf '\033[?25l'
|
||||
}
|
||||
|
||||
show_cursor() {
|
||||
printf '\033[?25h'
|
||||
}
|
||||
|
||||
# Keyboard input handling (simple and robust)
|
||||
read_key() {
|
||||
local key rest
|
||||
IFS= read -rsn1 key || return 1
|
||||
# Use macOS bash 3.2 compatible read syntax
|
||||
IFS= read -r -s -n 1 key || return 1
|
||||
|
||||
# Some terminals can yield empty on Enter with -n1; treat as ENTER
|
||||
if [[ -z "$key" ]]; then
|
||||
@@ -91,23 +100,23 @@ read_key() {
|
||||
|
||||
case "$key" in
|
||||
$'\n'|$'\r') echo "ENTER" ;;
|
||||
' ') echo " " ;;
|
||||
' ') echo "SPACE" ;;
|
||||
'q'|'Q') echo "QUIT" ;;
|
||||
'a'|'A') echo "ALL" ;;
|
||||
'n'|'N') echo "NONE" ;;
|
||||
'?') echo "HELP" ;;
|
||||
$'\x1b')
|
||||
# Read the next two bytes within 1s; works well on macOS bash 3.2
|
||||
if IFS= read -rsn2 -t 1 rest 2>/dev/null; then
|
||||
if IFS= read -r -s -n 2 -t 1 rest 2>/dev/null; then
|
||||
case "$rest" in
|
||||
"[A") echo "UP" ;;
|
||||
"[B") echo "DOWN" ;;
|
||||
"[C") echo "RIGHT" ;;
|
||||
"[D") echo "LEFT" ;;
|
||||
*) echo "ESC" ;;
|
||||
*) echo "OTHER" ;;
|
||||
esac
|
||||
else
|
||||
echo "ESC"
|
||||
echo "OTHER"
|
||||
fi
|
||||
;;
|
||||
*) echo "OTHER" ;;
|
||||
@@ -175,32 +184,6 @@ get_directory_size_bytes() {
|
||||
du -sk "$path" 2>/dev/null | cut -f1 | awk '{print $1 * 1024}' || echo "0"
|
||||
}
|
||||
|
||||
# Safe file operation with backup
|
||||
safe_remove() {
|
||||
local path="$1"
|
||||
local backup_dir="${2:-/tmp/mole_backup_$(date +%s)}"
|
||||
local backup_enabled="${MOLE_BACKUP_ENABLED:-true}"
|
||||
|
||||
if [[ ! -e "$path" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$backup_enabled" == "true" ]]; then
|
||||
# Create backup directory if it doesn't exist
|
||||
mkdir -p "$backup_dir" 2>/dev/null || return 1
|
||||
|
||||
local basename_path
|
||||
basename_path=$(basename "$path")
|
||||
|
||||
if ! cp -R "$path" "$backup_dir/$basename_path" 2>/dev/null; then
|
||||
log_warning "Backup failed for $path, skipping removal"
|
||||
return 1
|
||||
fi
|
||||
log_info "Backup created at $backup_dir/$basename_path"
|
||||
fi
|
||||
|
||||
rm -rf "$path" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Permission checks
|
||||
check_sudo() {
|
||||
@@ -223,165 +206,179 @@ request_sudo() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Configuration management
|
||||
readonly CONFIG_FILE="${HOME}/.config/mole/config"
|
||||
|
||||
# Load configuration with defaults
|
||||
# Load basic configuration
|
||||
load_config() {
|
||||
# Default configuration
|
||||
MOLE_LOG_LEVEL="${MOLE_LOG_LEVEL:-INFO}"
|
||||
MOLE_AUTO_CONFIRM="${MOLE_AUTO_CONFIRM:-false}"
|
||||
MOLE_BACKUP_ENABLED="${MOLE_BACKUP_ENABLED:-true}"
|
||||
MOLE_MAX_LOG_SIZE="${MOLE_MAX_LOG_SIZE:-1048576}"
|
||||
MOLE_PARALLEL_JOBS="${MOLE_PARALLEL_JOBS:-}" # Empty means auto-detect
|
||||
|
||||
# Load user configuration if exists
|
||||
if [[ -f "$CONFIG_FILE" ]]; then
|
||||
source "$CONFIG_FILE" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Save configuration
|
||||
save_config() {
|
||||
mkdir -p "$(dirname "$CONFIG_FILE")" 2>/dev/null || return 1
|
||||
cat > "$CONFIG_FILE" << EOF
|
||||
# Mole Configuration File
|
||||
# Generated on $(date)
|
||||
|
||||
# Log level: DEBUG, INFO, WARNING, ERROR
|
||||
MOLE_LOG_LEVEL="$MOLE_LOG_LEVEL"
|
||||
|
||||
# Auto confirm operations (true/false)
|
||||
MOLE_AUTO_CONFIRM="$MOLE_AUTO_CONFIRM"
|
||||
|
||||
# Enable backup before deletion (true/false)
|
||||
MOLE_BACKUP_ENABLED="$MOLE_BACKUP_ENABLED"
|
||||
|
||||
# Maximum log file size in bytes
|
||||
MOLE_MAX_LOG_SIZE="$MOLE_MAX_LOG_SIZE"
|
||||
|
||||
# Number of parallel jobs for operations (empty = auto-detect)
|
||||
MOLE_PARALLEL_JOBS="$MOLE_PARALLEL_JOBS"
|
||||
EOF
|
||||
}
|
||||
|
||||
# Progress tracking
|
||||
# Use parameter expansion for portable global initialization (macOS bash lacks declare -g).
|
||||
: "${PROGRESS_CURRENT:=0}"
|
||||
: "${PROGRESS_TOTAL:=0}"
|
||||
: "${PROGRESS_MESSAGE:=}"
|
||||
|
||||
# Initialize progress tracking
|
||||
init_progress() {
|
||||
PROGRESS_CURRENT=0
|
||||
PROGRESS_TOTAL="$1"
|
||||
PROGRESS_MESSAGE="${2:-Processing}"
|
||||
}
|
||||
|
||||
# Update progress
|
||||
update_progress() {
|
||||
PROGRESS_CURRENT="$1"
|
||||
local message="${2:-$PROGRESS_MESSAGE}"
|
||||
local percentage=$((PROGRESS_CURRENT * 100 / PROGRESS_TOTAL))
|
||||
|
||||
# Create progress bar
|
||||
local bar_length=20
|
||||
local filled_length=$((percentage * bar_length / 100))
|
||||
local bar=""
|
||||
|
||||
for ((i=0; i<filled_length; i++)); do
|
||||
bar="${bar}█"
|
||||
done
|
||||
|
||||
for ((i=filled_length; i<bar_length; i++)); do
|
||||
bar="${bar}░"
|
||||
done
|
||||
|
||||
printf "\r${BLUE}[%s] %3d%% %s (%d/%d)${NC}" "$bar" "$percentage" "$message" "$PROGRESS_CURRENT" "$PROGRESS_TOTAL"
|
||||
|
||||
if [[ $PROGRESS_CURRENT -eq $PROGRESS_TOTAL ]]; then
|
||||
echo
|
||||
fi
|
||||
}
|
||||
|
||||
# Spinner for indeterminate progress
|
||||
: "${SPINNER_PID:=}"
|
||||
|
||||
start_spinner() {
|
||||
local message="${1:-Working}"
|
||||
stop_spinner # Stop any existing spinner
|
||||
|
||||
(
|
||||
local spin='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
|
||||
local i=0
|
||||
while true; do
|
||||
printf "\r${BLUE}%s %s${NC}" "${spin:$i:1}" "$message"
|
||||
((i++))
|
||||
if [[ $i -eq ${#spin} ]]; then
|
||||
i=0
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
) &
|
||||
SPINNER_PID=$!
|
||||
}
|
||||
|
||||
stop_spinner() {
|
||||
if [[ -n "$SPINNER_PID" ]]; then
|
||||
kill "$SPINNER_PID" 2>/dev/null || true
|
||||
wait "$SPINNER_PID" 2>/dev/null || true
|
||||
SPINNER_PID=""
|
||||
printf "\r\033[K" # Clear the line
|
||||
fi
|
||||
}
|
||||
|
||||
# Calculate optimal parallel jobs based on system resources
|
||||
get_optimal_parallel_jobs() {
|
||||
local operation_type="${1:-default}"
|
||||
local optimal_parallel=4
|
||||
|
||||
# Try to detect optimal parallel jobs based on CPU cores
|
||||
if command -v nproc >/dev/null 2>&1; then
|
||||
optimal_parallel=$(nproc)
|
||||
elif command -v sysctl >/dev/null 2>&1; then
|
||||
optimal_parallel=$(sysctl -n hw.ncpu 2>/dev/null || echo 4)
|
||||
fi
|
||||
|
||||
# Apply operation-specific limits
|
||||
case "$operation_type" in
|
||||
"scan")
|
||||
# For scanning: min 2, max 8
|
||||
if [[ $optimal_parallel -lt 2 ]]; then
|
||||
optimal_parallel=2
|
||||
elif [[ $optimal_parallel -gt 8 ]]; then
|
||||
optimal_parallel=8
|
||||
fi
|
||||
;;
|
||||
"clean")
|
||||
# For file operations: min 2, max 6 (more conservative)
|
||||
if [[ $optimal_parallel -lt 2 ]]; then
|
||||
optimal_parallel=2
|
||||
elif [[ $optimal_parallel -gt 6 ]]; then
|
||||
optimal_parallel=6
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
# Default: min 2, max 4 (safest)
|
||||
if [[ $optimal_parallel -lt 2 ]]; then
|
||||
optimal_parallel=2
|
||||
elif [[ $optimal_parallel -gt 4 ]]; then
|
||||
optimal_parallel=4
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# Use configured value if available, otherwise use calculated optimal
|
||||
if [[ -n "${MOLE_PARALLEL_JOBS:-}" ]]; then
|
||||
echo "$MOLE_PARALLEL_JOBS"
|
||||
else
|
||||
echo "$optimal_parallel"
|
||||
fi
|
||||
}
|
||||
|
||||
# Initialize configuration on sourcing
|
||||
load_config
|
||||
|
||||
# ============================================================================
|
||||
# App Management Functions
|
||||
# ============================================================================
|
||||
|
||||
# Essential system and critical app patterns that should never be removed
|
||||
readonly PRESERVED_BUNDLE_PATTERNS=(
|
||||
# System essentials
|
||||
"com.apple.*"
|
||||
"loginwindow"
|
||||
"dock"
|
||||
"systempreferences"
|
||||
"finder"
|
||||
"safari"
|
||||
"keychain*"
|
||||
"security*"
|
||||
"bluetooth*"
|
||||
"wifi*"
|
||||
"network*"
|
||||
"tcc"
|
||||
"notification*"
|
||||
"accessibility*"
|
||||
"universalaccess*"
|
||||
"HIToolbox*"
|
||||
"textinput*"
|
||||
"TextInput*"
|
||||
"keyboard*"
|
||||
"Keyboard*"
|
||||
"inputsource*"
|
||||
"InputSource*"
|
||||
"keylayout*"
|
||||
"KeyLayout*"
|
||||
"GlobalPreferences"
|
||||
".GlobalPreferences"
|
||||
|
||||
# Input methods (critical for international users)
|
||||
"com.tencent.inputmethod.*"
|
||||
"com.sogou.*"
|
||||
"com.baidu.*"
|
||||
"*.inputmethod.*"
|
||||
"*input*"
|
||||
"*inputmethod*"
|
||||
"*InputMethod*"
|
||||
"*ime*"
|
||||
"*IME*"
|
||||
|
||||
# Cleanup and system tools (avoid infinite loops and preserve licenses)
|
||||
"com.nektony.*" # App Cleaner & Uninstaller
|
||||
"com.macpaw.*" # CleanMyMac, CleanMaster
|
||||
"com.freemacsoft.AppCleaner" # AppCleaner
|
||||
"com.omnigroup.omnidisksweeper" # OmniDiskSweeper
|
||||
"com.daisydiskapp.*" # DaisyDisk
|
||||
"com.tunabellysoftware.*" # Disk Utility apps
|
||||
"com.grandperspectiv.*" # GrandPerspective
|
||||
"com.binaryfruit.*" # FusionCast
|
||||
"com.CharlesProxy.*" # Charles Proxy (paid)
|
||||
"com.proxyman.*" # Proxyman (paid)
|
||||
"com.getpaw.*" # Paw (paid)
|
||||
|
||||
# Security and password managers (critical data)
|
||||
"com.1password.*" # 1Password
|
||||
"com.agilebits.*" # 1Password legacy
|
||||
"com.lastpass.*" # LastPass
|
||||
"com.dashlane.*" # Dashlane
|
||||
"com.bitwarden.*" # Bitwarden
|
||||
"com.keepassx.*" # KeePassXC
|
||||
|
||||
# Development tools (licenses and settings)
|
||||
"com.jetbrains.*" # JetBrains IDEs (paid licenses)
|
||||
"com.sublimetext.*" # Sublime Text (paid)
|
||||
"com.panic.transmit*" # Transmit (paid)
|
||||
"com.sequelpro.*" # Database tools
|
||||
"com.sequel-ace.*"
|
||||
"com.tinyapp.*" # TablePlus (paid)
|
||||
|
||||
# Design tools (expensive licenses)
|
||||
"com.adobe.*" # Adobe Creative Suite
|
||||
"com.bohemiancoding.*" # Sketch
|
||||
"com.figma.*" # Figma
|
||||
"com.framerx.*" # Framer
|
||||
"com.zeplin.*" # Zeplin
|
||||
"com.invisionapp.*" # InVision
|
||||
"com.principle.*" # Principle
|
||||
|
||||
# Productivity (important data and licenses)
|
||||
"com.omnigroup.*" # OmniFocus, OmniGraffle, etc.
|
||||
"com.culturedcode.*" # Things
|
||||
"com.todoist.*" # Todoist
|
||||
"com.bear-writer.*" # Bear
|
||||
"com.typora.*" # Typora
|
||||
"com.ulyssesapp.*" # Ulysses
|
||||
"com.literatureandlatte.*" # Scrivener
|
||||
"com.dayoneapp.*" # Day One
|
||||
|
||||
# Media and entertainment (licenses)
|
||||
"com.spotify.client" # Spotify (premium accounts)
|
||||
"com.apple.FinalCutPro" # Final Cut Pro
|
||||
"com.apple.Motion" # Motion
|
||||
"com.apple.Compressor" # Compressor
|
||||
"com.blackmagic-design.*" # DaVinci Resolve
|
||||
"com.pixelmatorteam.*" # Pixelmator
|
||||
)
|
||||
|
||||
# Check if bundle should be preserved (system/critical apps)
|
||||
should_preserve_bundle() {
|
||||
local bundle_id="$1"
|
||||
for pattern in "${PRESERVED_BUNDLE_PATTERNS[@]}"; do
|
||||
if [[ "$bundle_id" == $pattern ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Find and list app-related files (consolidated from duplicates)
|
||||
find_app_files() {
|
||||
local bundle_id="$1"
|
||||
local app_name="$2"
|
||||
local -a files_to_clean=()
|
||||
|
||||
# Application Support
|
||||
[[ -d ~/Library/Application\ Support/"$app_name" ]] && files_to_clean+=("$HOME/Library/Application Support/$app_name")
|
||||
[[ -d ~/Library/Application\ Support/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Application Support/$bundle_id")
|
||||
|
||||
# Caches
|
||||
[[ -d ~/Library/Caches/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Caches/$bundle_id")
|
||||
|
||||
# Preferences
|
||||
[[ -f ~/Library/Preferences/"$bundle_id".plist ]] && files_to_clean+=("$HOME/Library/Preferences/$bundle_id.plist")
|
||||
|
||||
# Logs
|
||||
[[ -d ~/Library/Logs/"$app_name" ]] && files_to_clean+=("$HOME/Library/Logs/$app_name")
|
||||
[[ -d ~/Library/Logs/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Logs/$bundle_id")
|
||||
|
||||
# Saved Application State
|
||||
[[ -d ~/Library/Saved\ Application\ State/"$bundle_id".savedState ]] && files_to_clean+=("$HOME/Library/Saved Application State/$bundle_id.savedState")
|
||||
|
||||
# Containers (sandboxed apps)
|
||||
[[ -d ~/Library/Containers/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Containers/$bundle_id")
|
||||
|
||||
# Group Containers
|
||||
while IFS= read -r -d '' container; do
|
||||
files_to_clean+=("$container")
|
||||
done < <(find ~/Library/Group\ Containers -name "*$bundle_id*" -type d -print0 2>/dev/null)
|
||||
|
||||
# Only print if array has elements to avoid unbound variable error
|
||||
if [[ ${#files_to_clean[@]} -gt 0 ]]; then
|
||||
printf '%s\n' "${files_to_clean[@]}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Calculate total size of files (consolidated from duplicates)
|
||||
calculate_total_size() {
|
||||
local files="$1"
|
||||
local total_kb=0
|
||||
|
||||
while IFS= read -r file; do
|
||||
if [[ -n "$file" && -e "$file" ]]; then
|
||||
local size_kb=$(du -sk "$file" 2>/dev/null | awk '{print $1}' || echo "0")
|
||||
((total_kb += size_kb))
|
||||
fi
|
||||
done <<< "$files"
|
||||
|
||||
echo "$total_kb"
|
||||
}
|
||||
|
||||
369
lib/menu.sh
369
lib/menu.sh
@@ -1,369 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Simple interactive menu selector with arrow key support
|
||||
# No external dependencies, compatible with most bash versions
|
||||
|
||||
declare -a menu_options=()
|
||||
declare -i selected=0
|
||||
declare -i menu_size=0
|
||||
|
||||
# ANSI escape sequences (allow reuse when sourced after lib/common.sh)
|
||||
if [[ -z "${ESC+x}" ]]; then
|
||||
readonly ESC=$'\033'
|
||||
fi
|
||||
readonly UP="${ESC}[A"
|
||||
readonly DOWN="${ESC}[B"
|
||||
readonly ENTER=$'\n'
|
||||
readonly CLEAR_LINE="${ESC}[2K"
|
||||
readonly HIDE_CURSOR="${ESC}[?25l"
|
||||
readonly SHOW_CURSOR="${ESC}[?25h"
|
||||
|
||||
# Set terminal to raw mode for reading single characters
|
||||
setup_terminal() {
|
||||
# Block until at least 1 byte to avoid false ENTER on empty reads
|
||||
stty -echo -icanon min 1 time 0
|
||||
}
|
||||
|
||||
# Restore terminal to normal mode
|
||||
restore_terminal() {
|
||||
stty echo icanon
|
||||
printf "%s" "$SHOW_CURSOR"
|
||||
}
|
||||
|
||||
# Draw the menu
|
||||
draw_menu() {
|
||||
local force_full_redraw="${1:-true}"
|
||||
printf "%s" "$HIDE_CURSOR"
|
||||
|
||||
if [[ "$force_full_redraw" == "true" ]]; then
|
||||
# Full redraw: clear and redraw all lines
|
||||
for ((i = 0; i < menu_size; i++)); do
|
||||
printf "\r%s" "$CLEAR_LINE"
|
||||
|
||||
if [[ $i -eq $selected ]]; then
|
||||
printf "▶ \033[1;32m%s\033[0m\n" "${menu_options[i]}"
|
||||
else
|
||||
printf " %s\n" "${menu_options[i]}"
|
||||
fi
|
||||
done
|
||||
|
||||
# Move cursor back to the beginning and save position
|
||||
printf "${ESC}[%dA" $menu_size
|
||||
printf "${ESC}7" # Save cursor position
|
||||
else
|
||||
# Quick update: only update changed lines
|
||||
printf "${ESC}8" # Restore cursor position
|
||||
|
||||
for ((i = 0; i < menu_size; i++)); do
|
||||
printf "\r%s" "$CLEAR_LINE"
|
||||
|
||||
if [[ $i -eq $selected ]]; then
|
||||
printf "▶ \033[1;32m%s\033[0m\n" "${menu_options[i]}"
|
||||
else
|
||||
printf " %s\n" "${menu_options[i]}"
|
||||
fi
|
||||
done
|
||||
|
||||
# Move cursor back to the beginning
|
||||
printf "${ESC}[%dA" $menu_size
|
||||
printf "${ESC}7" # Save cursor position again
|
||||
fi
|
||||
}
|
||||
|
||||
# Read a single key
|
||||
read_key() {
|
||||
local key
|
||||
IFS= read -rsn1 key 2>/dev/null || return 1
|
||||
|
||||
case "$key" in
|
||||
$'\033')
|
||||
local key2 key3
|
||||
if IFS= read -rsn1 -t 0.2 key2 2>/dev/null; then
|
||||
if [[ "$key2" == "[" ]]; then
|
||||
if IFS= read -rsn1 -t 0.2 key3 2>/dev/null; then
|
||||
case "$key3" in
|
||||
'A') echo "UP" ;;
|
||||
'B') echo "DOWN" ;;
|
||||
'C') echo "RIGHT" ;;
|
||||
'D') echo "LEFT" ;;
|
||||
*) echo "OTHER" ;;
|
||||
esac
|
||||
else
|
||||
echo "OTHER"
|
||||
fi
|
||||
else
|
||||
echo "OTHER"
|
||||
fi
|
||||
else
|
||||
echo "OTHER"
|
||||
fi
|
||||
;;
|
||||
$'\n'|$'\r') echo "ENTER" ;;
|
||||
' ') echo " " ;;
|
||||
'q'|'Q') echo "QUIT" ;;
|
||||
*) echo "$key" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Main menu function
|
||||
# Usage: show_menu "Title" "option1" "option2" "option3" ...
|
||||
show_menu() {
|
||||
local title="$1"
|
||||
shift
|
||||
|
||||
# Initialize menu options
|
||||
menu_options=("$@")
|
||||
menu_size=${#menu_options[@]}
|
||||
selected=0
|
||||
|
||||
# Check if we have options
|
||||
if [[ $menu_size -eq 0 ]]; then
|
||||
echo "Error: No menu options provided" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Setup terminal
|
||||
setup_terminal
|
||||
trap restore_terminal EXIT INT TERM
|
||||
|
||||
# Display title
|
||||
if [[ -n "$title" ]]; then
|
||||
printf "\n\033[1;34m%s\033[0m\n\n" "$title"
|
||||
fi
|
||||
|
||||
# Initial draw
|
||||
draw_menu true
|
||||
|
||||
# Main loop
|
||||
local first_iteration=true
|
||||
while true; do
|
||||
local key=$(read_key)
|
||||
|
||||
case "$key" in
|
||||
"UP")
|
||||
((selected--))
|
||||
if [[ $selected -lt 0 ]]; then
|
||||
selected=$((menu_size - 1))
|
||||
fi
|
||||
draw_menu false # Quick update
|
||||
;;
|
||||
"DOWN")
|
||||
((selected++))
|
||||
if [[ $selected -ge $menu_size ]]; then
|
||||
selected=0
|
||||
fi
|
||||
draw_menu false # Quick update
|
||||
;;
|
||||
"ENTER")
|
||||
# Clear the menu
|
||||
for ((i = 0; i < menu_size; i++)); do
|
||||
printf "\r%s\n" "$CLEAR_LINE" >&2
|
||||
done
|
||||
printf "${ESC}[%dA" $menu_size >&2
|
||||
|
||||
# Show selection
|
||||
printf "Selected: \033[1;32m%s\033[0m\n\n" "${menu_options[selected]}"
|
||||
|
||||
restore_terminal
|
||||
return $selected
|
||||
;;
|
||||
"q"|"Q")
|
||||
restore_terminal
|
||||
echo "Cancelled." >&2
|
||||
return 255
|
||||
;;
|
||||
[0-9])
|
||||
# Jump to numbered option
|
||||
local num=$((key - 1))
|
||||
if [[ $num -ge 0 && $num -lt $menu_size ]]; then
|
||||
selected=$num
|
||||
draw_menu
|
||||
fi
|
||||
;;
|
||||
# Ignore other keys
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Multi-select menu function
|
||||
# Usage: show_multi_menu "Title" "option1" "option2" "option3" ...
|
||||
show_multi_menu() {
|
||||
local title="$1"
|
||||
shift
|
||||
|
||||
# Initialize menu options
|
||||
menu_options=("$@")
|
||||
menu_size=${#menu_options[@]}
|
||||
selected=0
|
||||
|
||||
# Array to track selected items
|
||||
declare -a selected_items=()
|
||||
for ((i = 0; i < menu_size; i++)); do
|
||||
selected_items[i]=false
|
||||
done
|
||||
|
||||
# Check if we have options
|
||||
if [[ $menu_size -eq 0 ]]; then
|
||||
echo "Error: No menu options provided" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Setup terminal
|
||||
setup_terminal
|
||||
trap restore_terminal EXIT INT TERM
|
||||
|
||||
# Display title
|
||||
if [[ -n "$title" ]]; then
|
||||
printf "\n\033[1;34m%s\033[0m\n" "$title" >&2
|
||||
printf "\033[0;36mUse SPACE to select/deselect, ENTER to confirm, Q to quit\033[0m\n\n" >&2
|
||||
fi
|
||||
|
||||
# Draw multi-select menu
|
||||
draw_multi_menu() {
|
||||
local force_full_redraw="${1:-true}"
|
||||
printf "%s" "$HIDE_CURSOR" >&2
|
||||
|
||||
if [[ "$force_full_redraw" == "true" ]]; then
|
||||
# Full redraw
|
||||
for ((i = 0; i < menu_size; i++)); do
|
||||
printf "\r%s" "$CLEAR_LINE" >&2
|
||||
|
||||
local checkbox="☐"
|
||||
if [[ ${selected_items[i]} == "true" ]]; then
|
||||
checkbox="\033[1;32m☑\033[0m"
|
||||
fi
|
||||
|
||||
if [[ $i -eq $selected ]]; then
|
||||
printf "▶ %s \033[1;32m%s\033[0m\n" "$checkbox" "${menu_options[i]}" >&2
|
||||
else
|
||||
printf " %s %s\n" "$checkbox" "${menu_options[i]}" >&2
|
||||
fi
|
||||
done
|
||||
|
||||
# Move cursor back to the beginning and save position
|
||||
printf "${ESC}[%dA" $menu_size >&2
|
||||
printf "${ESC}7" >&2 # Save cursor position
|
||||
else
|
||||
# Quick update
|
||||
printf "${ESC}8" >&2 # Restore cursor position
|
||||
|
||||
for ((i = 0; i < menu_size; i++)); do
|
||||
printf "\r%s" "$CLEAR_LINE" >&2
|
||||
|
||||
local checkbox="☐"
|
||||
if [[ ${selected_items[i]} == "true" ]]; then
|
||||
checkbox="\033[1;32m☑\033[0m"
|
||||
fi
|
||||
|
||||
if [[ $i -eq $selected ]]; then
|
||||
printf "▶ %s \033[1;32m%s\033[0m\n" "$checkbox" "${menu_options[i]}" >&2
|
||||
else
|
||||
printf " %s %s\n" "$checkbox" "${menu_options[i]}" >&2
|
||||
fi
|
||||
done
|
||||
|
||||
# Move cursor back to the beginning and save position
|
||||
printf "${ESC}[%dA" $menu_size >&2
|
||||
printf "${ESC}7" >&2 # Save cursor position
|
||||
fi
|
||||
}
|
||||
|
||||
# Initial draw
|
||||
draw_multi_menu true
|
||||
|
||||
# Main loop
|
||||
while true; do
|
||||
local key=$(read_key)
|
||||
|
||||
case "$key" in
|
||||
"UP")
|
||||
((selected--))
|
||||
if [[ $selected -lt 0 ]]; then
|
||||
selected=$((menu_size - 1))
|
||||
fi
|
||||
draw_multi_menu false # Quick update
|
||||
;;
|
||||
"DOWN")
|
||||
((selected++))
|
||||
if [[ $selected -ge $menu_size ]]; then
|
||||
selected=0
|
||||
fi
|
||||
draw_multi_menu false # Quick update
|
||||
;;
|
||||
" ")
|
||||
# Toggle selection
|
||||
if [[ ${selected_items[selected]} == "true" ]]; then
|
||||
selected_items[selected]="false"
|
||||
else
|
||||
selected_items[selected]="true"
|
||||
fi
|
||||
draw_multi_menu false # Quick update
|
||||
;;
|
||||
"ENTER")
|
||||
# Clear the menu
|
||||
for ((i = 0; i < menu_size; i++)); do
|
||||
printf "\r%s\n" "$CLEAR_LINE" >&2
|
||||
done
|
||||
printf "${ESC}[%dA" $menu_size >&2
|
||||
|
||||
# Show selections to stderr so it doesn't interfere with return value
|
||||
local has_selection=false
|
||||
printf "Selected items:\n" >&2
|
||||
for ((i = 0; i < menu_size; i++)); do
|
||||
if [[ ${selected_items[i]} == "true" ]]; then
|
||||
printf " \033[1;32m%s\033[0m\n" "${menu_options[i]}" >&2
|
||||
has_selection=true
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $has_selection == "false" ]]; then
|
||||
printf " None\n" >&2
|
||||
fi
|
||||
printf "\n" >&2
|
||||
|
||||
restore_terminal
|
||||
|
||||
# Return selected indices as space-separated string
|
||||
local result=""
|
||||
for ((i = 0; i < menu_size; i++)); do
|
||||
if [[ ${selected_items[i]} == "true" ]]; then
|
||||
result="$result $i"
|
||||
fi
|
||||
done
|
||||
echo "${result# }" # Remove leading space
|
||||
return 0
|
||||
;;
|
||||
"q"|"Q"|"ESC")
|
||||
restore_terminal
|
||||
echo "Cancelled." >&2
|
||||
return 255
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Example usage function
|
||||
demo_menu() {
|
||||
echo "=== Single Select Demo ==="
|
||||
if show_menu "Choose an action:" "Install package" "Update system" "Clean cache" "Exit"; then
|
||||
local choice=$?
|
||||
echo "You selected option $choice"
|
||||
fi
|
||||
|
||||
echo -e "\n=== Multi Select Demo ==="
|
||||
local selections=$(show_multi_menu "Choose packages to install:" "git" "vim" "curl" "htop" "tree")
|
||||
if [[ $? -eq 0 && -n "$selections" ]]; then
|
||||
echo "Selected indices: $selections"
|
||||
# Convert indices to actual values
|
||||
local options=("git" "vim" "curl" "htop" "tree")
|
||||
echo "Selected packages:"
|
||||
for idx in $selections; do
|
||||
echo " - ${options[idx]}"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
# If script is run directly, show demo
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
demo_menu
|
||||
fi
|
||||
@@ -1,64 +1,29 @@
|
||||
#!/bin/bash
|
||||
# Paginated menu with arrow key navigation
|
||||
|
||||
# Proper paginated menu with arrow key navigation
|
||||
# 10 items per page, up/down to navigate, space to select, left/right to change pages
|
||||
set -euo pipefail
|
||||
|
||||
# Terminal control functions
|
||||
hide_cursor() { printf '\033[?25l' >&2; }
|
||||
show_cursor() { printf '\033[?25h' >&2; }
|
||||
clear_screen() { printf '\033[2J\033[H' >&2; }
|
||||
enter_alt_screen() { tput smcup >/dev/null 2>&1 || true; }
|
||||
leave_alt_screen() { tput rmcup >/dev/null 2>&1 || true; }
|
||||
disable_wrap() { printf '\033[?7l' >&2; } # disable line wrap
|
||||
enable_wrap() { printf '\033[?7h' >&2; }
|
||||
enter_alt_screen() { tput smcup 2>/dev/null || true; }
|
||||
leave_alt_screen() { tput rmcup 2>/dev/null || true; }
|
||||
|
||||
# Read single key with arrow key support (macOS bash 3.2 friendly)
|
||||
read_key() {
|
||||
local key seq
|
||||
IFS= read -rsn1 key || return 1
|
||||
|
||||
# Some terminals may yield empty on Enter with -n1
|
||||
if [[ -z "$key" ]]; then
|
||||
echo "ENTER"
|
||||
return 0
|
||||
fi
|
||||
|
||||
case "$key" in
|
||||
$'\033')
|
||||
# Read next two bytes within 1s: "[A", "[B", ...
|
||||
if IFS= read -rsn2 -t 1 seq 2>/dev/null; then
|
||||
case "$seq" in
|
||||
"[A") echo "UP" ;;
|
||||
"[B") echo "DOWN" ;;
|
||||
"[C") echo "RIGHT" ;;
|
||||
"[D") echo "LEFT" ;;
|
||||
*) echo "OTHER" ;;
|
||||
esac
|
||||
else
|
||||
echo "OTHER"
|
||||
fi
|
||||
;;
|
||||
' ') echo "SPACE" ;;
|
||||
$'\n'|$'\r') echo "ENTER" ;;
|
||||
'q'|'Q') echo "QUIT" ;;
|
||||
'a'|'A') echo "ALL" ;;
|
||||
'n'|'N') echo "NONE" ;;
|
||||
'?') echo "HELP" ;;
|
||||
*) echo "OTHER" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Paginated multi-select menu
|
||||
# Main paginated multi-select menu function
|
||||
paginated_multi_select() {
|
||||
local title="$1"
|
||||
shift
|
||||
local -a items=("$@")
|
||||
|
||||
# Validation
|
||||
if [[ ${#items[@]} -eq 0 ]]; then
|
||||
echo "No items provided" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local total_items=${#items[@]}
|
||||
local items_per_page=10 # Reduced for better readability
|
||||
local items_per_page=10
|
||||
local total_pages=$(( (total_items + items_per_page - 1) / items_per_page ))
|
||||
local current_page=0
|
||||
local cursor_pos=0 # Position within current page (0-9)
|
||||
local cursor_pos=0
|
||||
local -a selected=()
|
||||
|
||||
# Initialize selection array
|
||||
@@ -69,78 +34,60 @@ paginated_multi_select() {
|
||||
# Cleanup function
|
||||
cleanup() {
|
||||
show_cursor
|
||||
stty echo 2>/dev/null || true
|
||||
stty icanon 2>/dev/null || true
|
||||
stty echo icanon 2>/dev/null || true
|
||||
leave_alt_screen
|
||||
enable_wrap
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# Setup terminal for optimal responsiveness
|
||||
stty -echo -icanon min 1 time 0 2>/dev/null || true
|
||||
# Setup terminal
|
||||
stty -echo -icanon 2>/dev/null || true
|
||||
enter_alt_screen
|
||||
disable_wrap
|
||||
hide_cursor
|
||||
|
||||
# Main display function
|
||||
first_draw=1
|
||||
# Helper: print one cleared line
|
||||
print_line() {
|
||||
printf "\r\033[2K%s\n" "$1" >&2
|
||||
}
|
||||
# Helper functions
|
||||
print_line() { printf "\r\033[2K%s\n" "$1" >&2; }
|
||||
|
||||
# Helper: render one item line at given page position
|
||||
render_item_line() {
|
||||
local page_pos=$1
|
||||
local start_idx=$((current_page * items_per_page))
|
||||
local i=$((start_idx + page_pos))
|
||||
render_item() {
|
||||
local idx=$1 is_current=$2
|
||||
local checkbox="☐"
|
||||
local cursor_marker=" "
|
||||
[[ ${selected[i]} == true ]] && checkbox="☑"
|
||||
if [[ $page_pos -eq $cursor_pos ]]; then
|
||||
cursor_marker="▶ "
|
||||
printf "\r\033[2K\033[7m%s%s %s\033[0m\n" "$cursor_marker" "$checkbox" "${items[i]}" >&2
|
||||
[[ ${selected[idx]} == true ]] && checkbox="☑"
|
||||
|
||||
if [[ $is_current == true ]]; then
|
||||
printf "\r\033[2K\033[7m▶ %s %s\033[0m\n" "$checkbox" "${items[idx]}" >&2
|
||||
else
|
||||
printf "\r\033[2K%s%s %s\n" "$cursor_marker" "$checkbox" "${items[i]}" >&2
|
||||
printf "\r\033[2K %s %s\n" "$checkbox" "${items[idx]}" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# Helper: move cursor to top-left anchor saved by tput sc
|
||||
to_anchor() { tput rc >/dev/null 2>&1 || true; }
|
||||
|
||||
# Full draw of entire screen - simplified for stability
|
||||
# Draw the complete menu
|
||||
draw_menu() {
|
||||
# Always do full screen redraw for reliability
|
||||
clear_screen
|
||||
printf "\033[H\033[J" >&2 # Clear screen and move to top
|
||||
|
||||
# Simple header
|
||||
printf "%s\n" "$title" >&2
|
||||
printf "%s\n" "$(printf '=%.0s' $(seq 1 ${#title}))" >&2
|
||||
# Header
|
||||
printf "%s\n%s\n" "$title" "$(printf '=%.0s' $(seq 1 ${#title}))" >&2
|
||||
|
||||
# Status bar
|
||||
# Status
|
||||
local selected_count=0
|
||||
for ((i = 0; i < total_items; i++)); do
|
||||
[[ ${selected[i]} == true ]] && ((selected_count++))
|
||||
done
|
||||
|
||||
printf "Page %d/%d │ Total: %d │ Selected: %d\n" \
|
||||
printf "Page %d/%d │ Total: %d │ Selected: %d\n\n" \
|
||||
$((current_page + 1)) $total_pages $total_items $selected_count >&2
|
||||
print_line ""
|
||||
|
||||
# Calculate page boundaries
|
||||
# Items for current page
|
||||
local start_idx=$((current_page * items_per_page))
|
||||
local end_idx=$((start_idx + items_per_page - 1))
|
||||
[[ $end_idx -ge $total_items ]] && end_idx=$((total_items - 1))
|
||||
|
||||
# Display items for current page
|
||||
for ((i = start_idx; i <= end_idx; i++)); do
|
||||
local page_pos=$((i - start_idx))
|
||||
render_item_line "$page_pos"
|
||||
local is_current=false
|
||||
[[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true
|
||||
render_item $i $is_current
|
||||
done
|
||||
|
||||
# Fill empty slots to always print items_per_page lines
|
||||
local items_on_page=$((end_idx - start_idx + 1))
|
||||
for ((i = items_on_page; i < items_per_page; i++)); do
|
||||
# Fill empty slots
|
||||
local items_shown=$((end_idx - start_idx + 1))
|
||||
for ((i = items_shown; i < items_per_page; i++)); do
|
||||
print_line ""
|
||||
done
|
||||
|
||||
@@ -148,45 +95,42 @@ paginated_multi_select() {
|
||||
print_line "↑↓: Navigate | Space: Select | Enter: Confirm | Q: Exit"
|
||||
}
|
||||
|
||||
# Help screen
|
||||
# Show help screen
|
||||
show_help() {
|
||||
clear_screen
|
||||
echo "App Uninstaller - Help" >&2
|
||||
echo "======================" >&2
|
||||
echo >&2
|
||||
echo " ↑ / ↓ Navigate up/down" >&2
|
||||
echo " ← / → Previous/next page" >&2
|
||||
echo " Space Select/deselect app" >&2
|
||||
echo " Enter Confirm selection" >&2
|
||||
echo " A Select all" >&2
|
||||
echo " N Deselect all" >&2
|
||||
echo " Q Exit" >&2
|
||||
echo >&2
|
||||
read -p "Press any key to continue..." -n 1 >&2
|
||||
printf "\033[H\033[J" >&2
|
||||
cat >&2 << 'EOF'
|
||||
Help - Navigation Controls
|
||||
==========================
|
||||
|
||||
↑ / ↓ Navigate up/down
|
||||
Space Select/deselect item
|
||||
Enter Confirm selection
|
||||
A Select all
|
||||
N Deselect all
|
||||
Q Exit
|
||||
|
||||
Press any key to continue...
|
||||
EOF
|
||||
read -n 1 -s >&2
|
||||
}
|
||||
|
||||
# Main loop - simplified to always do full redraws for stability
|
||||
# Main interaction loop
|
||||
while true; do
|
||||
draw_menu # Always full redraw to avoid display issues
|
||||
|
||||
draw_menu
|
||||
local key=$(read_key)
|
||||
|
||||
# Immediate exit key
|
||||
if [[ "$key" == "QUIT" ]]; then
|
||||
cleanup
|
||||
return 1
|
||||
fi
|
||||
|
||||
case "$key" in
|
||||
"QUIT") cleanup; return 1 ;;
|
||||
"UP")
|
||||
if [[ $cursor_pos -gt 0 ]]; then
|
||||
((cursor_pos--))
|
||||
elif [[ $current_page -gt 0 ]]; then
|
||||
((current_page--))
|
||||
cursor_pos=$((items_per_page - 1))
|
||||
# Calculate cursor position for new page
|
||||
local start_idx=$((current_page * items_per_page))
|
||||
local end_idx=$((start_idx + items_per_page - 1))
|
||||
[[ $end_idx -ge $total_items ]] && cursor_pos=$((total_items - start_idx - 1))
|
||||
local items_on_page=$((total_items - start_idx))
|
||||
[[ $items_on_page -gt $items_per_page ]] && items_on_page=$items_per_page
|
||||
cursor_pos=$((items_on_page - 1))
|
||||
fi
|
||||
;;
|
||||
"DOWN")
|
||||
@@ -201,33 +145,13 @@ paginated_multi_select() {
|
||||
cursor_pos=0
|
||||
fi
|
||||
;;
|
||||
"LEFT")
|
||||
if [[ $current_page -gt 0 ]]; then
|
||||
((current_page--))
|
||||
cursor_pos=0
|
||||
fi
|
||||
;;
|
||||
"RIGHT")
|
||||
if [[ $current_page -lt $((total_pages - 1)) ]]; then
|
||||
((current_page++))
|
||||
cursor_pos=0
|
||||
fi
|
||||
;;
|
||||
"PGUP")
|
||||
current_page=0
|
||||
cursor_pos=0
|
||||
;;
|
||||
"PGDOWN")
|
||||
current_page=$((total_pages - 1))
|
||||
cursor_pos=0
|
||||
;;
|
||||
"SPACE")
|
||||
local actual_idx=$((current_page * items_per_page + cursor_pos))
|
||||
if [[ $actual_idx -lt $total_items ]]; then
|
||||
if [[ ${selected[actual_idx]} == true ]]; then
|
||||
selected[actual_idx]=false
|
||||
local idx=$((current_page * items_per_page + cursor_pos))
|
||||
if [[ $idx -lt $total_items ]]; then
|
||||
if [[ ${selected[idx]} == true ]]; then
|
||||
selected[idx]=false
|
||||
else
|
||||
selected[actual_idx]=true
|
||||
selected[idx]=true
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
@@ -241,11 +165,9 @@ paginated_multi_select() {
|
||||
selected[i]=false
|
||||
done
|
||||
;;
|
||||
"HELP")
|
||||
show_help
|
||||
;;
|
||||
"HELP") show_help ;;
|
||||
"ENTER")
|
||||
# If no items are selected, select the current item
|
||||
# Auto-select current item if nothing selected
|
||||
local has_selection=false
|
||||
for ((i = 0; i < total_items; i++)); do
|
||||
if [[ ${selected[i]} == true ]]; then
|
||||
@@ -255,58 +177,38 @@ paginated_multi_select() {
|
||||
done
|
||||
|
||||
if [[ $has_selection == false ]]; then
|
||||
# Select current item under cursor
|
||||
local actual_idx=$((current_page * items_per_page + cursor_pos))
|
||||
if [[ $actual_idx -lt $total_items ]]; then
|
||||
selected[actual_idx]=true
|
||||
fi
|
||||
local idx=$((current_page * items_per_page + cursor_pos))
|
||||
[[ $idx -lt $total_items ]] && selected[idx]=true
|
||||
fi
|
||||
|
||||
# Build result
|
||||
# Store result in global variable instead of returning via stdout
|
||||
local result=""
|
||||
for ((i = 0; i < total_items; i++)); do
|
||||
if [[ ${selected[i]} == true ]]; then
|
||||
result="$result $i"
|
||||
fi
|
||||
done
|
||||
cleanup
|
||||
echo "${result# }"
|
||||
local final_result="${result# }"
|
||||
|
||||
# Remove the trap to avoid cleanup on normal exit
|
||||
trap - EXIT INT TERM
|
||||
|
||||
# Store result in global variable
|
||||
MOLE_SELECTION_RESULT="$final_result"
|
||||
|
||||
# Manually cleanup terminal before returning
|
||||
show_cursor
|
||||
stty echo icanon 2>/dev/null || true
|
||||
leave_alt_screen
|
||||
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
# Ignore unrecognized keys - just continue the loop
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Demo function
|
||||
demo_paginated() {
|
||||
echo "=== Paginated Multi-select Demo ===" >&2
|
||||
|
||||
# Create test data
|
||||
local test_items=()
|
||||
for i in {1..35}; do
|
||||
test_items+=("Application $i ($(( (RANDOM % 500) + 50 ))MB)")
|
||||
done
|
||||
|
||||
local result
|
||||
result=$(paginated_multi_select "Choose Applications to Uninstall" "${test_items[@]}")
|
||||
local exit_code=$?
|
||||
|
||||
if [[ $exit_code -eq 0 ]]; then
|
||||
if [[ -n "$result" ]]; then
|
||||
echo "Selected indices: $result" >&2
|
||||
echo "Count: $(echo $result | wc -w | tr -d ' ')" >&2
|
||||
else
|
||||
echo "No items selected" >&2
|
||||
fi
|
||||
else
|
||||
echo "Selection cancelled" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# Run demo if script is executed directly
|
||||
# Export function for external use
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
demo_paginated
|
||||
echo "This is a library file. Source it from other scripts." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user