1
0
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:
Tw93
2025-09-30 00:43:52 +08:00
parent 6897b95ca6
commit e2fd35f8a7
11 changed files with 988 additions and 1820 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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