diff --git a/GUIDE.md b/GUIDE.md index f4c694d..5bf1d2a 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -118,6 +118,28 @@ mole --help 可以查看所有可用的命令和说明。 +### 磁盘空间分析 🆕 + +```bash +mole analyze +``` + +交互式查看哪些文件和文件夹最占空间,帮助你快速找到并清理大文件。 + +**基本操作:** + +- `↑` `↓`:上下选择 +- `回车`:进入文件夹 / 打开文件预览 +- `←`:返回上级 +- `Delete`:删除(需确认) +- `q`:退出 + +**小提示:** + +- 文件夹会按大小排序,一目了然 +- 文本文件可以直接预览,其他文件用系统默认应用打开 +- 超过 15 项会自动分页,继续按方向键滚动查看 + --- ## 第五步:注意事项 diff --git a/bin/analyze.sh b/bin/analyze.sh new file mode 100755 index 0000000..d861c17 --- /dev/null +++ b/bin/analyze.sh @@ -0,0 +1,2222 @@ +#!/bin/bash +# Mole - Disk Space Analyzer Module +# Fast disk analysis with mdfind + du hybrid approach + +set -euo pipefail + +# Get script directory for sourcing libraries +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB_DIR="$(dirname "$SCRIPT_DIR")/lib" + +# Source required libraries +# shellcheck source=../lib/common.sh +source "$LIB_DIR/common.sh" + +# Constants +readonly CACHE_DIR="${HOME}/.config/mole/cache" +readonly TEMP_PREFIX="/tmp/mole_analyze_$$" +readonly MIN_LARGE_FILE_SIZE="1000000000" # 1GB +readonly MIN_MEDIUM_FILE_SIZE="100000000" # 100MB +readonly MIN_SMALL_FILE_SIZE="10000000" # 10MB + +# Global state +declare -a SCAN_RESULTS=() +declare -a DIR_RESULTS=() +declare -a LARGE_FILES=() +declare SCAN_PID="" +declare TOTAL_SIZE=0 +declare CURRENT_PATH="$HOME" +declare CURRENT_DEPTH=1 + +# UI State +declare CURSOR_POS=0 +declare SORT_MODE="size" # size, name, time +declare VIEW_MODE="overview" # overview, detail, files + +# Cleanup on exit +cleanup() { + show_cursor + rm -f "$TEMP_PREFIX"* 2>/dev/null || true + if [[ -n "$SCAN_PID" ]] && kill -0 "$SCAN_PID" 2>/dev/null; then + kill "$SCAN_PID" 2>/dev/null || true + fi +} + +trap cleanup EXIT INT TERM + +# ============================================================================ +# Scanning Functions +# ============================================================================ + +# Fast scan using mdfind for large files +scan_large_files() { + local target_path="$1" + local output_file="$2" + + if ! command -v mdfind &>/dev/null; then + return 1 + fi + + # Scan files > 1GB + mdfind -onlyin "$target_path" "kMDItemFSSize > $MIN_LARGE_FILE_SIZE" 2>/dev/null | \ + while IFS= read -r file; do + if [[ -f "$file" ]]; then + local size=$(stat -f%z "$file" 2>/dev/null || echo "0") + echo "$size|$file" + fi + done | sort -t'|' -k1 -rn > "$output_file" +} + +# Scan medium files (100MB - 1GB) +scan_medium_files() { + local target_path="$1" + local output_file="$2" + + if ! command -v mdfind &>/dev/null; then + return 1 + fi + + mdfind -onlyin "$target_path" \ + "kMDItemFSSize > $MIN_MEDIUM_FILE_SIZE && kMDItemFSSize < $MIN_LARGE_FILE_SIZE" 2>/dev/null | \ + while IFS= read -r file; do + if [[ -f "$file" ]]; then + local size=$(stat -f%z "$file" 2>/dev/null || echo "0") + echo "$size|$file" + fi + done | sort -t'|' -k1 -rn > "$output_file" +} + +# Scan top-level directories with du (optimized with parallel) +scan_directories() { + local target_path="$1" + local output_file="$2" + local depth="${3:-1}" + + # Check if we can use parallel processing + if command -v xargs &>/dev/null && [[ $depth -eq 1 ]]; then + # Fast parallel scan for depth 1 + find "$target_path" -mindepth 1 -maxdepth 1 -type d -print0 2>/dev/null | \ + xargs -0 -P 4 -I {} du -sk {} 2>/dev/null | \ + sort -rn | \ + while IFS=$'\t' read -r size path; do + echo "$((size * 1024))|$path" + done > "$output_file" + else + # Standard du scan + du -d "$depth" -k "$target_path" 2>/dev/null | \ + sort -rn | \ + while IFS=$'\t' read -r size path; do + # Skip if path is the target itself at depth > 0 + if [[ "$path" != "$target_path" ]]; then + echo "$((size * 1024))|$path" + fi + done > "$output_file" + fi +} + +# Aggregate files by directory +aggregate_by_directory() { + local file_list="$1" + local output_file="$2" + + awk -F'|' '{ + path = $2 + size = $1 + # Get parent directory + n = split(path, parts, "/") + dir = "" + for(i=1; i "$output_file" +} + +# Get cache file path for a directory +get_cache_file() { + local target_path="$1" + local path_hash=$(echo "$target_path" | md5 2>/dev/null || echo "$target_path" | shasum | cut -d' ' -f1) + echo "$CACHE_DIR/scan_${path_hash}.cache" +} + +# Check if cache is valid (less than 1 hour old) +is_cache_valid() { + local cache_file="$1" + local max_age="${2:-3600}" # Default 1 hour + + if [[ ! -f "$cache_file" ]]; then + return 1 + fi + + local cache_age=$(($(date +%s) - $(stat -f%m "$cache_file" 2>/dev/null || echo 0))) + if [[ $cache_age -lt $max_age ]]; then + return 0 + fi + + return 1 +} + +# Save scan results to cache +save_to_cache() { + local cache_file="$1" + local temp_large="$TEMP_PREFIX.large" + local temp_medium="$TEMP_PREFIX.medium" + local temp_dirs="$TEMP_PREFIX.dirs" + local temp_agg="$TEMP_PREFIX.agg" + + # Create cache directory + mkdir -p "$(dirname "$cache_file")" 2>/dev/null || return 1 + + # Bundle all scan results into cache file + { + echo "### LARGE ###" + [[ -f "$temp_large" ]] && cat "$temp_large" + echo "### MEDIUM ###" + [[ -f "$temp_medium" ]] && cat "$temp_medium" + echo "### DIRS ###" + [[ -f "$temp_dirs" ]] && cat "$temp_dirs" + echo "### AGG ###" + [[ -f "$temp_agg" ]] && cat "$temp_agg" + } > "$cache_file" 2>/dev/null +} + +# Load scan results from cache +load_from_cache() { + local cache_file="$1" + local temp_large="$TEMP_PREFIX.large" + local temp_medium="$TEMP_PREFIX.medium" + local temp_dirs="$TEMP_PREFIX.dirs" + local temp_agg="$TEMP_PREFIX.agg" + + local section="" + while IFS= read -r line; do + case "$line" in + "### LARGE ###") section="large" ;; + "### MEDIUM ###") section="medium" ;; + "### DIRS ###") section="dirs" ;; + "### AGG ###") section="agg" ;; + *) + case "$section" in + "large") echo "$line" >> "$temp_large" ;; + "medium") echo "$line" >> "$temp_medium" ;; + "dirs") echo "$line" >> "$temp_dirs" ;; + "agg") echo "$line" >> "$temp_agg" ;; + esac + ;; + esac + done < "$cache_file" +} + +# Main scan coordinator +perform_scan() { + local target_path="$1" + local force_rescan="${2:-false}" + + # Check cache first + local cache_file=$(get_cache_file "$target_path") + if [[ "$force_rescan" != "true" ]] && is_cache_valid "$cache_file" 3600; then + log_info "Loading cached results for $target_path..." + load_from_cache "$cache_file" + log_success "Cache loaded!" + return 0 + fi + + log_info "Analyzing disk space in $target_path..." + echo "" + + # Create temp files + local temp_large="$TEMP_PREFIX.large" + local temp_medium="$TEMP_PREFIX.medium" + local temp_dirs="$TEMP_PREFIX.dirs" + local temp_agg="$TEMP_PREFIX.agg" + + # Start parallel scans + { + scan_large_files "$target_path" "$temp_large" & + scan_medium_files "$target_path" "$temp_medium" & + scan_directories "$target_path" "$temp_dirs" "$CURRENT_DEPTH" & + wait + } & + SCAN_PID=$! + + # Show spinner with progress while scanning + local spinner_chars="⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" + local i=0 + local elapsed=0 + hide_cursor + + # Progress messages (short and dynamic) + local messages=( + "Finding large files" + "Scanning directories" + "Calculating sizes" + "Finishing up" + ) + local msg_idx=0 + + while kill -0 "$SCAN_PID" 2>/dev/null; do + # Show different messages based on elapsed time + local current_msg="" + if [[ $elapsed -lt 5 ]]; then + current_msg="${messages[0]}" + elif [[ $elapsed -lt 15 ]]; then + current_msg="${messages[1]}" + elif [[ $elapsed -lt 25 ]]; then + current_msg="${messages[2]}" + else + current_msg="${messages[3]}" + fi + + printf "\r${BLUE}%s${NC} %s" \ + "${spinner_chars:$i:1}" "$current_msg" + + i=$(( (i + 1) % 10 )) + ((elapsed++)) + sleep 0.1 + done + wait "$SCAN_PID" 2>/dev/null || true + printf "\r%80s\r" "" # Clear spinner line + show_cursor + + # Aggregate results + if [[ -f "$temp_large" ]] && [[ -s "$temp_large" ]]; then + aggregate_by_directory "$temp_large" "$temp_agg" + fi + + # Save to cache + save_to_cache "$cache_file" + + log_success "Scan complete!" +} + +# ============================================================================ +# Visualization Functions +# ============================================================================ + +# Generate progress bar +generate_bar() { + local current="$1" + local max="$2" + local width="${3:-20}" + + if [[ "$max" -eq 0 ]]; then + printf "%${width}s" "" | tr ' ' '░' + return + fi + + local filled=$((current * width / max)) + local empty=$((width - filled)) + + # Ensure non-negative + [[ $filled -lt 0 ]] && filled=0 + [[ $empty -lt 0 ]] && empty=0 + + local bar="" + if [[ $filled -gt 0 ]]; then + bar=$(printf "%${filled}s" "" | tr ' ' '█') + fi + if [[ $empty -gt 0 ]]; then + bar="${bar}$(printf "%${empty}s" "" | tr ' ' '░')" + fi + + echo "$bar" +} + +# Calculate percentage +calc_percentage() { + local part="$1" + local total="$2" + + if [[ "$total" -eq 0 ]]; then + echo "0" + return + fi + + echo "$part" "$total" | awk '{printf "%.1f", ($1/$2)*100}' +} + +# Display large files summary (compact version) +display_large_files_compact() { + local temp_large="$TEMP_PREFIX.large" + + if [[ ! -f "$temp_large" ]] || [[ ! -s "$temp_large" ]]; then + return + fi + + log_header "📊 Top Large Files" + echo "" + + local count=0 + local total_size=0 + local total_count=$(wc -l < "$temp_large" | tr -d ' ') + + # Calculate total size + while IFS='|' read -r size path; do + ((total_size += size)) + done < "$temp_large" + + # Show top 5 only + while IFS='|' read -r size path; do + if [[ $count -ge 5 ]]; then + break + fi + + local human_size=$(bytes_to_human "$size") + local filename=$(basename "$path") + local dirname=$(basename "$(dirname "$path")") + + printf " ${GREEN}%-8s${NC} 📄 %-40s ${GRAY}%s${NC}\n" \ + "$human_size" "${filename:0:40}" "$dirname" + + ((count++)) + done < "$temp_large" + + echo "" + local total_human=$(bytes_to_human "$total_size") + echo " ${GRAY}Found $total_count large files (>1GB), totaling $total_human${NC}" + echo "" +} + +# Display large files summary (full version) +display_large_files() { + local temp_large="$TEMP_PREFIX.large" + + if [[ ! -f "$temp_large" ]] || [[ ! -s "$temp_large" ]]; then + log_header "📊 Large Files (>1GB)" + echo "" + echo " ${GRAY}No files larger than 1GB found${NC}" + echo "" + return + fi + + log_header "📊 Large Files (>1GB)" + echo "" + + local count=0 + local max_size=0 + + # Get max size for progress bar + max_size=$(head -1 "$temp_large" | cut -d'|' -f1) + [[ -z "$max_size" ]] && max_size=1 + + while IFS='|' read -r size path; do + if [[ $count -ge 10 ]]; then + break + fi + + local human_size=$(bytes_to_human "$size") + local percentage=$(calc_percentage "$size" "$max_size") + local bar=$(generate_bar "$size" "$max_size" 20) + local filename=$(basename "$path") + local dirname=$(dirname "$path" | sed "s|^$HOME|~|") + + printf " %s [${GREEN}%s${NC}] %7s\n" "$bar" "$human_size" "" + printf " 📄 %s\n" "$filename" + printf " ${GRAY}%s${NC}\n\n" "$dirname" + + ((count++)) + done < "$temp_large" + + # Show total count + local total_count=$(wc -l < "$temp_large" | tr -d ' ') + if [[ $total_count -gt 10 ]]; then + echo " ${GRAY}... and $((total_count - 10)) more files${NC}" + echo "" + fi +} + +# Display directory summary (compact version) +display_directories_compact() { + local temp_dirs="$TEMP_PREFIX.dirs" + + if [[ ! -f "$temp_dirs" ]] || [[ ! -s "$temp_dirs" ]]; then + return + fi + + log_header "📁 Top Directories" + echo "" + + local count=0 + local total_size=0 + + # Calculate total + while IFS='|' read -r size path; do + ((total_size += size)) + done < "$temp_dirs" + [[ $total_size -eq 0 ]] && total_size=1 + + # Show top 8 directories in compact format + while IFS='|' read -r size path; do + if [[ $count -ge 8 ]]; then + break + fi + + local human_size=$(bytes_to_human "$size") + local percentage=$(calc_percentage "$size" "$total_size") + local dirname=$(basename "$path") + + # Simple bar (10 chars) + local bar_width=10 + local percentage_int=${percentage%.*} # Remove decimal part + local filled=$((percentage_int * bar_width / 100)) + [[ $filled -gt $bar_width ]] && filled=$bar_width + [[ $filled -lt 0 ]] && filled=0 + local empty=$((bar_width - filled)) + [[ $empty -lt 0 ]] && empty=0 + local bar="" + if [[ $filled -gt 0 ]]; then + bar=$(printf "%${filled}s" "" | tr ' ' '█') + fi + if [[ $empty -gt 0 ]]; then + bar="${bar}$(printf "%${empty}s" "" | tr ' ' '░')" + fi + + printf " ${BLUE}%-8s${NC} %s ${GRAY}%3s%%${NC} 📁 %s\n" \ + "$human_size" "$bar" "$percentage" "$dirname" + + ((count++)) + done < "$temp_dirs" + echo "" +} + +# Display directory summary (full version) +display_directories() { + local temp_dirs="$TEMP_PREFIX.dirs" + + if [[ ! -f "$temp_dirs" ]] || [[ ! -s "$temp_dirs" ]]; then + return + fi + + log_header "📁 Top Directories" + echo "" + + local count=0 + local max_size=0 + local total_size=0 + + # Calculate total and max for percentages + max_size=$(head -1 "$temp_dirs" | cut -d'|' -f1) + [[ -z "$max_size" ]] && max_size=1 + + while IFS='|' read -r size path; do + ((total_size += size)) + done < "$temp_dirs" + + [[ $total_size -eq 0 ]] && total_size=1 + + # Display directories + while IFS='|' read -r size path; do + if [[ $count -ge 15 ]]; then + break + fi + + local human_size=$(bytes_to_human "$size") + local percentage=$(calc_percentage "$size" "$total_size") + local bar=$(generate_bar "$size" "$max_size" 20) + local display_path=$(echo "$path" | sed "s|^$HOME|~|") + local dirname=$(basename "$path") + + printf " %s [${BLUE}%s${NC}] %5s%%\n" "$bar" "$human_size" "$percentage" + printf " 📁 %s\n\n" "$display_path" + + ((count++)) + done < "$temp_dirs" +} + +# Display hotspot directories (many large files) +display_hotspots() { + local temp_agg="$TEMP_PREFIX.agg" + + if [[ ! -f "$temp_agg" ]] || [[ ! -s "$temp_agg" ]]; then + return + fi + + log_header "🔥 Hotspot Directories (High File Concentration)" + echo "" + + local count=0 + while IFS='|' read -r size path file_count; do + if [[ $count -ge 8 ]]; then + break + fi + + local human_size=$(bytes_to_human "$size") + local display_path=$(echo "$path" | sed "s|^$HOME|~|") + + printf " 📍 %s\n" "$display_path" + printf " ${GREEN}%s${NC} in ${YELLOW}%d${NC} large files\n\n" \ + "$human_size" "$file_count" + + ((count++)) + done < "$temp_agg" +} + +# Display smart cleanup suggestions (compact version) +display_cleanup_suggestions_compact() { + local suggestions_count=0 + local top_suggestion="" + local potential_space=0 + local action_command="" + + # Check common cache locations (only if analyzing Library/Caches or system paths) + if [[ "$CURRENT_PATH" == "$HOME/Library/Caches"* ]] || [[ "$CURRENT_PATH" == "$HOME/Library"* ]]; then + if [[ -d "$HOME/Library/Caches" ]]; then + local cache_size=$(du -sk "$HOME/Library/Caches" 2>/dev/null | cut -f1) + if [[ $cache_size -gt 1048576 ]]; then # > 1GB + local human=$(bytes_to_human $((cache_size * 1024))) + top_suggestion="Clear app caches ($human)" + action_command="mole clean" + ((potential_space += cache_size * 1024)) + ((suggestions_count++)) + fi + fi + fi + + # Check Downloads folder (only if analyzing Downloads) + if [[ "$CURRENT_PATH" == "$HOME/Downloads"* ]]; then + local old_files=$(find "$CURRENT_PATH" -type f -mtime +90 2>/dev/null | wc -l | tr -d ' ') + if [[ $old_files -gt 0 ]]; then + [[ -z "$top_suggestion" ]] && top_suggestion="$old_files files older than 90 days found" + [[ -z "$action_command" ]] && action_command="manually review old files" + ((suggestions_count++)) + fi + fi + + # Check for large disk images in current path + if command -v mdfind &>/dev/null; then + local dmg_count=$(mdfind -onlyin "$CURRENT_PATH" \ + "kMDItemFSSize > 500000000 && kMDItemDisplayName == '*.dmg'" 2>/dev/null | wc -l | tr -d ' ') + if [[ $dmg_count -gt 0 ]]; then + local dmg_size=$(mdfind -onlyin "$CURRENT_PATH" \ + "kMDItemFSSize > 500000000 && kMDItemDisplayName == '*.dmg'" 2>/dev/null | \ + xargs stat -f%z 2>/dev/null | awk '{sum+=$1} END {print sum}') + local dmg_human=$(bytes_to_human "$dmg_size") + [[ -z "$top_suggestion" ]] && top_suggestion="$dmg_count DMG files ($dmg_human) can be removed" + [[ -z "$action_command" ]] && action_command="manually delete DMG files" + ((potential_space += dmg_size)) + ((suggestions_count++)) + fi + fi + + # Check Xcode (only if in developer paths) + if [[ "$CURRENT_PATH" == "$HOME/Library/Developer"* ]] && [[ -d "$HOME/Library/Developer/Xcode/DerivedData" ]]; then + local xcode_size=$(du -sk "$HOME/Library/Developer/Xcode/DerivedData" 2>/dev/null | cut -f1) + if [[ $xcode_size -gt 10485760 ]]; then + local xcode_human=$(bytes_to_human $((xcode_size * 1024))) + [[ -z "$top_suggestion" ]] && top_suggestion="Xcode cache ($xcode_human) can be cleared" + [[ -z "$action_command" ]] && action_command="mole clean" + ((potential_space += xcode_size * 1024)) + ((suggestions_count++)) + fi + fi + + # Check for duplicates in current path + if command -v mdfind &>/dev/null; then + local dup_count=$(mdfind -onlyin "$CURRENT_PATH" "kMDItemFSSize > 10000000" 2>/dev/null | \ + xargs -I {} stat -f "%z" {} 2>/dev/null | sort | uniq -d | wc -l | tr -d ' ') + if [[ $dup_count -gt 5 ]]; then + [[ -z "$top_suggestion" ]] && top_suggestion="$dup_count potential duplicate files detected" + ((suggestions_count++)) + fi + fi + + if [[ $suggestions_count -gt 0 ]]; then + log_header "💡 Quick Insights" + echo "" + echo " ${YELLOW}✨ $top_suggestion${NC}" + if [[ $suggestions_count -gt 1 ]]; then + echo " ${GRAY}... and $((suggestions_count - 1)) more insights${NC}" + fi + if [[ $potential_space -gt 0 ]]; then + local space_human=$(bytes_to_human "$potential_space") + echo " ${GREEN}Potential recovery: ~$space_human${NC}" + fi + echo "" + if [[ -n "$action_command" ]]; then + if [[ "$action_command" == "mole clean" ]]; then + echo " ${GRAY}→ Run${NC} ${YELLOW}mole clean${NC} ${GRAY}to cleanup system files${NC}" + else + echo " ${GRAY}→ Review and ${NC}${YELLOW}$action_command${NC}" + fi + fi + echo "" + fi +} + +# Display smart cleanup suggestions (full version) +display_cleanup_suggestions() { + log_header "💡 Smart Cleanup Suggestions" + echo "" + + local suggestions=() + + # Check common cache locations + if [[ -d "$HOME/Library/Caches" ]]; then + local cache_size=$(du -sk "$HOME/Library/Caches" 2>/dev/null | cut -f1) + if [[ $cache_size -gt 1048576 ]]; then # > 1GB + local human=$(bytes_to_human $((cache_size * 1024))) + suggestions+=(" 🗑️ Clear application caches: $human") + fi + fi + + # Check Downloads folder + if [[ -d "$HOME/Downloads" ]]; then + local old_files=$(find "$HOME/Downloads" -type f -mtime +90 2>/dev/null | wc -l | tr -d ' ') + if [[ $old_files -gt 0 ]]; then + suggestions+=(" 📥 Clean old downloads: $old_files files older than 90 days") + fi + fi + + # Check for large disk images + if command -v mdfind &>/dev/null; then + local dmg_count=$(mdfind -onlyin "$HOME" \ + "kMDItemFSSize > 500000000 && kMDItemDisplayName == '*.dmg'" 2>/dev/null | wc -l | tr -d ' ') + if [[ $dmg_count -gt 0 ]]; then + suggestions+=(" 💿 Remove disk images: $dmg_count DMG files >500MB") + fi + fi + + # Check Xcode derived data + if [[ -d "$HOME/Library/Developer/Xcode/DerivedData" ]]; then + local xcode_size=$(du -sk "$HOME/Library/Developer/Xcode/DerivedData" 2>/dev/null | cut -f1) + if [[ $xcode_size -gt 10485760 ]]; then # > 10GB + local human=$(bytes_to_human $((xcode_size * 1024))) + suggestions+=(" 🔨 Clear Xcode cache: $human") + fi + fi + + # Check iOS device backups + if [[ -d "$HOME/Library/Application Support/MobileSync/Backup" ]]; then + local backup_size=$(du -sk "$HOME/Library/Application Support/MobileSync/Backup" 2>/dev/null | cut -f1) + if [[ $backup_size -gt 5242880 ]]; then # > 5GB + local human=$(bytes_to_human $((backup_size * 1024))) + suggestions+=(" 📱 Review iOS backups: $human") + fi + fi + + # Check for duplicate files (by size, quick heuristic) + if command -v mdfind &>/dev/null; then + local temp_dup="$TEMP_PREFIX.dup_check" + mdfind -onlyin "$CURRENT_PATH" "kMDItemFSSize > 10000000" 2>/dev/null | \ + xargs -I {} stat -f "%z" {} 2>/dev/null | \ + sort | uniq -d | wc -l | tr -d ' ' > "$temp_dup" 2>/dev/null || echo "0" > "$temp_dup" + local dup_count=$(cat "$temp_dup" 2>/dev/null || echo "0") + if [[ $dup_count -gt 5 ]]; then + suggestions+=(" 📋 Possible duplicates: $dup_count size matches in large files (>10MB)") + fi + fi + + # Display suggestions + if [[ ${#suggestions[@]} -gt 0 ]]; then + printf '%s\n' "${suggestions[@]}" + echo "" + echo " ${YELLOW}Tip:${NC} Run 'mole clean' to perform cleanup operations" + else + echo " ${GREEN}✓${NC} No obvious cleanup opportunities found" + fi + echo "" +} + +# Display overall disk situation summary +display_disk_summary() { + local temp_large="$TEMP_PREFIX.large" + local temp_dirs="$TEMP_PREFIX.dirs" + + # Calculate stats + local total_large_size=0 + local total_large_count=0 + local total_dirs_size=0 + local total_dirs_count=0 + + if [[ -f "$temp_large" ]]; then + total_large_count=$(wc -l < "$temp_large" 2>/dev/null | tr -d ' ') + while IFS='|' read -r size path; do + ((total_large_size += size)) + done < "$temp_large" + fi + + if [[ -f "$temp_dirs" ]]; then + total_dirs_count=$(wc -l < "$temp_dirs" 2>/dev/null | tr -d ' ') + while IFS='|' read -r size path; do + ((total_dirs_size += size)) + done < "$temp_dirs" + fi + + log_header "💾 Disk Situation" + + local target_display=$(echo "$CURRENT_PATH" | sed "s|^$HOME|~|") + echo " ${BLUE}Scanning:${NC} $target_display | ${BLUE}Free:${NC} $(get_free_space)" + + if [[ $total_large_count -gt 0 ]]; then + local large_human=$(bytes_to_human "$total_large_size") + echo " ${BLUE}Large Files:${NC} $total_large_count files ($large_human) | ${BLUE}Total:${NC} $(bytes_to_human "$total_dirs_size") in $total_dirs_count dirs" + elif [[ $total_dirs_size -gt 0 ]]; then + echo " ${BLUE}Total Scanned:${NC} $(bytes_to_human "$total_dirs_size") across $total_dirs_count directories" + fi + echo "" +} + +# Get file type icon and description +get_file_info() { + local path="$1" + local ext="${path##*.}" + local icon="" + local type="" + + case "$ext" in + dmg|iso|pkg) icon="📦" ; type="Installer" ;; + mov|mp4|avi|mkv|webm) icon="🎬" ; type="Video" ;; + zip|tar|gz|rar|7z) icon="🗜️" ; type="Archive" ;; + pdf) icon="📄" ; type="Document" ;; + jpg|jpeg|png|gif|heic) icon="🖼️" ; type="Image" ;; + key|ppt|pptx) icon="📊" ; type="Slides" ;; + log) icon="📝" ; type="Log" ;; + app) icon="⚙️" ; type="App" ;; + *) icon="📄" ; type="File" ;; + esac + + echo "$icon|$type" +} + +# Get file age in human readable format +get_file_age() { + local path="$1" + if [[ ! -f "$path" ]]; then + echo "N/A" + return + fi + + local mtime=$(stat -f%m "$path" 2>/dev/null || echo "0") + local now=$(date +%s) + local diff=$((now - mtime)) + local days=$((diff / 86400)) + + if [[ $days -lt 1 ]]; then + echo "Today" + elif [[ $days -eq 1 ]]; then + echo "1 day" + elif [[ $days -lt 30 ]]; then + echo "${days}d" + elif [[ $days -lt 365 ]]; then + local months=$((days / 30)) + echo "${months}mo" + else + local years=$((days / 365)) + echo "${years}yr" + fi +} + +# Display large files in compact table format +display_large_files_table() { + local temp_large="$TEMP_PREFIX.large" + + if [[ ! -f "$temp_large" ]] || [[ ! -s "$temp_large" ]]; then + return + fi + + log_header "🎯 What's Taking Up Space" + + # Table header + printf " %-4s %-10s %-8s %s\n" "TYPE" "SIZE" "AGE" "FILE" + printf " %s\n" "$(printf '%.0s─' {1..80})" + + local count=0 + while IFS='|' read -r size path; do + if [[ $count -ge 20 ]]; then + break + fi + + local human_size=$(bytes_to_human "$size") + local filename=$(basename "$path") + local ext="${filename##*.}" + local age=$(get_file_age "$path") + + # Get file info + local info=$(get_file_info "$path") + local icon="${info%|*}" + + # Truncate filename if too long + if [[ ${#filename} -gt 50 ]]; then + filename="${filename:0:47}..." + fi + + # Color based on file type + local color="" + case "$ext" in + dmg|iso|pkg) color="${RED}" ;; + mov|mp4|avi|mkv|webm|zip|tar|gz|rar|7z) color="${YELLOW}" ;; + log) color="${GRAY}" ;; + *) color="${NC}" ;; + esac + + printf " %b%-4s %-10s %-8s %s${NC}\n" \ + "$color" "$icon" "$human_size" "$age" "$filename" + + ((count++)) + done < "$temp_large" + + local total=$(wc -l < "$temp_large" | tr -d ' ') + if [[ $total -gt 20 ]]; then + echo " ${GRAY}... $((total - 20)) more files${NC}" + fi + echo "" +} + +# Display unified directory view in table format +display_unified_directories() { + local temp_dirs="$TEMP_PREFIX.dirs" + + if [[ ! -f "$temp_dirs" ]] || [[ ! -s "$temp_dirs" ]]; then + return + fi + + # Calculate total + local total_size=0 + while IFS='|' read -r size path; do + ((total_size += size)) + done < "$temp_dirs" + [[ $total_size -eq 0 ]] && total_size=1 + + echo " ${YELLOW}Top Directories:${NC}" + + # Table header + printf " %-30s %5s %10s %s\n" "DIRECTORY" "%" "SIZE" "CHART" + printf " %s\n" "$(printf '%.0s─' {1..75})" + + # Show top 10 directories + local count=0 + local chart_width=20 + while IFS='|' read -r size path; do + if [[ $count -ge 10 ]]; then + break + fi + + local percentage=$((size * 100 / total_size)) + local bar_width=$((percentage * chart_width / 100)) + [[ $bar_width -lt 1 ]] && bar_width=1 + + local dirname=$(basename "$path") + local human_size=$(bytes_to_human "$size") + + # Build compact bar + local bar="" + if [[ $bar_width -gt 0 ]]; then + bar=$(printf "%${bar_width}s" "" | tr ' ' '▓') + fi + local empty=$((chart_width - bar_width)) + if [[ $empty -gt 0 ]]; then + bar="${bar}$(printf "%${empty}s" "" | tr ' ' '░')" + fi + + # Truncate dirname if too long + local display_name="$dirname" + if [[ ${#dirname} -gt 28 ]]; then + display_name="${dirname:0:25}..." + fi + + # Color based on percentage + local color="${NC}" + if [[ $percentage -gt 50 ]]; then + color="${RED}" + elif [[ $percentage -gt 20 ]]; then + color="${YELLOW}" + else + color="${BLUE}" + fi + + printf " %b%-30s %4d%% %10s %s${NC}\n" \ + "$color" "$display_name" "$percentage" "$human_size" "$bar" + + ((count++)) + done < "$temp_dirs" + echo "" +} + +# Display context-aware recommendations +display_recommendations() { + echo " ${YELLOW}💡 Quick Actions:${NC}" + + if [[ "$CURRENT_PATH" == "$HOME/Downloads"* ]]; then + echo " → Delete ${RED}[Can Delete]${NC} items (installers/DMG)" + echo " → Review ${YELLOW}[Review]${NC} items (videos/archives)" + elif [[ "$CURRENT_PATH" == "$HOME/Library"* ]]; then + echo " → Run ${GREEN}mole clean${NC} to clear caches safely" + echo " → Check Xcode/developer caches if applicable" + else + echo " → Review ${RED}[Can Delete]${NC} and ${YELLOW}[Review]${NC} items" + echo " → Run ${GREEN}mole analyze ~/Library${NC} to check caches" + fi + echo "" +} + +# Display space chart (visual tree map style) +display_space_chart() { + local temp_dirs="$TEMP_PREFIX.dirs" + + if [[ ! -f "$temp_dirs" ]] || [[ ! -s "$temp_dirs" ]]; then + return + fi + + log_header "📊 Space Distribution" + echo "" + + # Calculate total + local total_size=0 + while IFS='|' read -r size path; do + ((total_size += size)) + done < "$temp_dirs" + [[ $total_size -eq 0 ]] && total_size=1 + + # Show top 5 as blocks + local count=0 + local chart_width=50 + while IFS='|' read -r size path; do + if [[ $count -ge 5 ]]; then + break + fi + + local percentage=$((size * 100 / total_size)) + local bar_width=$((percentage * chart_width / 100)) + [[ $bar_width -lt 1 ]] && bar_width=1 + + local dirname=$(basename "$path") + local human_size=$(bytes_to_human "$size") + + # Build visual bar + local bar="" + if [[ $bar_width -gt 0 ]]; then + bar=$(printf "%${bar_width}s" "" | tr ' ' '█') + fi + + printf " ${BLUE}%-15s${NC} %3d%% %s %s\n" \ + "${dirname:0:15}" "$percentage" "$bar" "$human_size" + + ((count++)) + done < "$temp_dirs" + echo "" +} + +# Display recent large files (added in last 30 days) +display_recent_large_files() { + log_header "🆕 Recent Large Files (Last 30 Days)" + echo "" + + if ! command -v mdfind &>/dev/null; then + echo " ${YELLOW}Note: mdfind not available${NC}" + echo "" + return + fi + + local temp_recent="$TEMP_PREFIX.recent" + + # Find files created in last 30 days, larger than 100MB + mdfind -onlyin "$CURRENT_PATH" \ + "kMDItemFSSize > 100000000 && kMDItemContentCreationDate >= \$time.today(-30)" 2>/dev/null | \ + while IFS= read -r file; do + if [[ -f "$file" ]]; then + local size=$(stat -f%z "$file" 2>/dev/null || echo "0") + local mtime=$(stat -f%m "$file" 2>/dev/null || echo "0") + echo "$size|$mtime|$file" + fi + done | sort -t'|' -k1 -rn | head -10 > "$temp_recent" + + if [[ ! -s "$temp_recent" ]]; then + echo " ${GRAY}No large files created recently${NC}" + echo "" + return + fi + + local count=0 + while IFS='|' read -r size mtime path; do + local human_size=$(bytes_to_human "$size") + local filename=$(basename "$path") + local dirname=$(dirname "$path" | sed "s|^$HOME|~|") + local days_ago=$(( ($(date +%s) - mtime) / 86400 )) + + printf " 📄 %s ${GRAY}(%s)${NC}\n" "$filename" "$human_size" + printf " ${GRAY}%s - %d days ago${NC}\n\n" "$dirname" "$days_ago" + + ((count++)) + done < "$temp_recent" +} + +# ============================================================================ +# Interactive Navigation +# ============================================================================ + +# Get list of subdirectories +get_subdirectories() { + local target="$1" + local temp_file="$2" + + find "$target" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | \ + while IFS= read -r dir; do + local size=$(du -sk "$dir" 2>/dev/null | cut -f1) + echo "$((size * 1024))|$dir" + done | sort -t'|' -k1 -rn > "$temp_file" +} + +# Display directory list for selection +display_directory_list() { + local temp_dirs="$TEMP_PREFIX.dirs" + local cursor_pos="${1:-0}" + + if [[ ! -f "$temp_dirs" ]] || [[ ! -s "$temp_dirs" ]]; then + return 1 + fi + + local idx=0 + local max_size=0 + local total_size=0 + + # Calculate totals + max_size=$(head -1 "$temp_dirs" | cut -d'|' -f1) + [[ -z "$max_size" ]] && max_size=1 + while IFS='|' read -r size path; do + ((total_size += size)) + done < "$temp_dirs" + [[ $total_size -eq 0 ]] && total_size=1 + + # Display with cursor + while IFS='|' read -r size path; do + local human_size=$(bytes_to_human "$size") + local percentage=$(calc_percentage "$size" "$total_size") + local bar=$(generate_bar "$size" "$max_size" 20) + local display_path=$(echo "$path" | sed "s|^$HOME|~|") + local dirname=$(basename "$path") + + # Highlight selected line + if [[ $idx -eq $cursor_pos ]]; then + printf " ${BLUE}▶${NC} %s [${GREEN}%s${NC}] %5s%% %s\n" \ + "$bar" "$human_size" "$percentage" "$dirname" + else + printf " %s [${BLUE}%s${NC}] %5s%% %s\n" \ + "$bar" "$human_size" "$percentage" "$dirname" + fi + + ((idx++)) + if [[ $idx -ge 15 ]]; then + break + fi + done < "$temp_dirs" + + return 0 +} + +# Get path at cursor position +get_path_at_cursor() { + local cursor_pos="$1" + local temp_dirs="$TEMP_PREFIX.dirs" + + if [[ ! -f "$temp_dirs" ]]; then + return 1 + fi + + local idx=0 + while IFS='|' read -r size path; do + if [[ $idx -eq $cursor_pos ]]; then + echo "$path" + return 0 + fi + ((idx++)) + done < "$temp_dirs" + + return 1 +} + +# Count available directories +count_directories() { + local temp_dirs="$TEMP_PREFIX.dirs" + if [[ ! -f "$temp_dirs" ]]; then + echo "0" + return + fi + local count=$(wc -l < "$temp_dirs" | tr -d ' ') + [[ $count -gt 15 ]] && count=15 + echo "$count" +} + +# Display interactive menu +display_interactive_menu() { + clear_screen + + log_header "🔍 Disk Space Analyzer" + echo "" + echo "📂 Current: ${BLUE}$(echo "$CURRENT_PATH" | sed "s|^$HOME|~|")${NC}" + echo "" + + # Show navigation hints + echo "${GRAY}↑↓ Navigate | → Drill Down | ← Go Back | f Files | t Types | q Quit${NC}" + echo "" + + # Display results based on view mode + case "$VIEW_MODE" in + "navigate") + log_header "📁 Select Directory" + echo "" + display_directory_list "$CURSOR_POS" + ;; + "files") + display_large_files + ;; + "types") + display_file_types + ;; + *) + display_large_files + display_directories + display_hotspots + display_cleanup_suggestions + ;; + esac +} + +# Analyze file types +display_file_types() { + local temp_types="$TEMP_PREFIX.types" + + log_header "📊 File Types Analysis" + echo "" + + if ! command -v mdfind &>/dev/null; then + echo " ${YELLOW}Note: mdfind not available, limited analysis${NC}" + return + fi + + # Analyze common file types + local -A type_map=( + ["Videos"]="kMDItemContentType == 'public.movie' || kMDItemContentType == 'public.video'" + ["Images"]="kMDItemContentType == 'public.image'" + ["Archives"]="kMDItemContentType == 'public.archive' || kMDItemContentType == 'public.zip-archive'" + ["Documents"]="kMDItemContentType == 'com.adobe.pdf' || kMDItemContentType == 'public.text'" + ["Audio"]="kMDItemContentType == 'public.audio'" + ) + + for type_name in "${!type_map[@]}"; do + local query="${type_map[$type_name]}" + local files=$(mdfind -onlyin "$CURRENT_PATH" "$query" 2>/dev/null) + local count=$(echo "$files" | grep -c . || echo "0") + local total_size=0 + + if [[ $count -gt 0 ]]; then + while IFS= read -r file; do + if [[ -f "$file" ]]; then + local fsize=$(stat -f%z "$file" 2>/dev/null || echo "0") + ((total_size += fsize)) + fi + done <<< "$files" + + if [[ $total_size -gt 0 ]]; then + local human_size=$(bytes_to_human "$total_size") + printf " 📦 %-12s %8s (%d files)\n" "$type_name:" "$human_size" "$count" + fi + fi + done + echo "" +} + +# Read a single key press +read_single_key() { + local key="" + # Read single character without waiting for Enter + if read -rsn1 key 2>/dev/null; then + echo "$key" + else + echo "q" + fi +} + +# Fast scan with progress display - optimized for speed +scan_directory_contents_fast() { + local dir_path="$1" + local output_file="$2" + local max_items="${3:-16}" + local show_progress="${4:-true}" + + # Auto-detect optimal parallel jobs - more aggressive + local num_jobs=12 + if command -v sysctl &>/dev/null; then + local cpu_cores=$(sysctl -n hw.ncpu 2>/dev/null || echo 12) + # Use more parallel jobs for better I/O utilization + num_jobs=$((cpu_cores * 2)) + [[ $num_jobs -gt 24 ]] && num_jobs=24 + [[ $num_jobs -lt 12 ]] && num_jobs=12 + fi + + local temp_dirs="$output_file.dirs" + local temp_files="$output_file.files" + + # Show initial scanning message + if [[ "$show_progress" == "true" ]]; then + printf "\033[H\033[J" >&2 + echo "" >&2 + printf " ${BLUE}📊 ⠋ Scanning...${NC}\r" >&2 + fi + + # Ultra-fast file scanning - batch stat for maximum speed + find "$dir_path" -mindepth 1 -maxdepth 1 -type f -print0 2>/dev/null | \ + xargs -0 -n 20 -P "$num_jobs" stat -f "%z|file|%N" 2>/dev/null > "$temp_files" & + local file_pid=$! + + # Smart directory scanning with aggressive optimization + # Strategy: Fast estimation first, accurate on-demand + find "$dir_path" -mindepth 1 -maxdepth 1 -type d -print0 2>/dev/null | \ + xargs -0 -n 1 -P "$num_jobs" sh -c ' + dir="$1" + size="" + + # Ultra-fast strategy: Try du with 1 second timeout only + du -sk "$dir" 2>/dev/null > /tmp/mole_du_$$ & + du_pid=$! + + # Wait only 1 second (aggressive!) + if ! sleep 1 || kill -0 $du_pid 2>/dev/null; then + # Still running after 1s = large dir, kill it + kill -9 $du_pid 2>/dev/null || true + wait $du_pid 2>/dev/null || true + rm -f /tmp/mole_du_$$ 2>/dev/null + size="" + else + # Completed within 1s, use the result + size=$(cat /tmp/mole_du_$$ 2>/dev/null | cut -f1) + rm -f /tmp/mole_du_$$ 2>/dev/null + fi + + # If timeout or empty, use instant estimation + if [[ -z "$size" ]] || [[ "$size" -eq 0 ]]; then + # Ultra-fast: count only immediate files (no recursion) + # Use + instead of xargs for batch stat (much faster) + size=$(find "$dir" -type f -maxdepth 1 -print0 2>/dev/null | \ + xargs -0 stat -f%z 2>/dev/null | \ + awk "BEGIN{sum=0} {sum+=\$1} END{print int(sum/1024)}") + + # If still 0, mark as unknown but ensure it shows up + [[ -z "$size" ]] || [[ "$size" -eq 0 ]] && size=1 + fi + echo "$((size * 1024))|dir|$dir" + ' _ > "$temp_dirs" 2>/dev/null & + local dir_pid=$! + + # Show progress while waiting + if [[ "$show_progress" == "true" ]]; then + local spinner=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') + local i=0 + local max_wait=30 # Reduced to 30 seconds (fast fail) + local elapsed=0 + local tick=0 + + while ( kill -0 "$dir_pid" 2>/dev/null || kill -0 "$file_pid" 2>/dev/null ); do + printf "\r ${BLUE}📊 ${spinner[$((i % 10))]} Scanning... (%ds)${NC}" "$elapsed" >&2 + ((i++)) + sleep 0.1 # Faster animation (100ms per frame) + ((tick++)) + + # Update elapsed seconds every 10 ticks (1 second) + if [[ $((tick % 10)) -eq 0 ]]; then + ((elapsed++)) + fi + + # Force kill if taking too long (30 seconds for fast response) + if [[ $elapsed -ge $max_wait ]]; then + kill -9 "$dir_pid" 2>/dev/null || true + kill -9 "$file_pid" 2>/dev/null || true + wait "$dir_pid" 2>/dev/null || true + wait "$file_pid" 2>/dev/null || true + printf "\r ${YELLOW}⚠️ Large directory - showing estimated sizes${NC}\n" >&2 + sleep 0.3 + break + fi + done + printf "\r\033[K" >&2 + fi + + # Wait for completion (non-blocking if already killed) + wait "$file_pid" 2>/dev/null || true + wait "$dir_pid" 2>/dev/null || true + + # Small delay only if scan was very fast (let user see the spinner briefly) + if [[ "$show_progress" == "true" ]] && [[ ${elapsed:-0} -lt 1 ]]; then + sleep 0.2 + fi + + # Combine and sort - only keep top items + # Ensure we handle empty files gracefully + > "$output_file" + if [[ -f "$temp_dirs" ]] || [[ -f "$temp_files" ]]; then + cat "$temp_dirs" "$temp_files" 2>/dev/null | sort -t'|' -k1 -rn | head -"$max_items" > "$output_file" || true + fi + + # Cleanup + rm -f "$temp_dirs" "$temp_files" 2>/dev/null +} + +# Calculate directory sizes and update (now only used for deep refresh) +calculate_dir_sizes() { + local items_file="$1" + local max_items="${2:-15}" # Only recalculate first 15 by default + local temp_file="${items_file}.calc" + + # Since we now scan with actual sizes, this function is mainly for refresh + # Just re-sort the existing data + sort -t'|' -k1 -rn "$items_file" > "$temp_file" + + # Only update if source file still exists (might have been deleted if user quit) + if [[ -f "$items_file" ]]; then + mv "$temp_file" "$items_file" 2>/dev/null || true + else + rm -f "$temp_file" 2>/dev/null || true + fi +} + +# Combine initial scan results (large files + directories) into one list +combine_initial_scan_results() { + local output_file="$1" + local temp_large="$TEMP_PREFIX.large" + local temp_dirs="$TEMP_PREFIX.dirs" + + > "$output_file" + + # Add directories + if [[ -f "$temp_dirs" ]]; then + while IFS='|' read -r size path; do + echo "$size|dir|$path" + done < "$temp_dirs" >> "$output_file" + fi + + # Add large files (only files in current directory, not subdirectories) + if [[ -f "$temp_large" ]]; then + while IFS='|' read -r size path; do + # Only include if parent directory is the current scan path + local parent=$(dirname "$path") + if [[ "$parent" == "$CURRENT_PATH" ]]; then + echo "$size|file|$path" + fi + done < "$temp_large" >> "$output_file" + fi + + # Sort by size + sort -t'|' -k1 -rn "$output_file" -o "$output_file" +} + +# Show all volumes overview and let user select +show_volumes_overview() { + local temp_volumes="$TEMP_PREFIX.volumes" + + # Collect all mounted volumes + { + # Root volume + local root_size=$(df -k / 2>/dev/null | tail -1 | awk '{print $3}') + echo "$((root_size * 1024))|/|Macintosh HD (Root)" + + # External volumes + if [[ -d "/Volumes" ]]; then + find /Volumes -mindepth 1 -maxdepth 1 -type d 2>/dev/null | while IFS= read -r vol; do + local vol_size=$(df -k "$vol" 2>/dev/null | tail -1 | awk '{print $3}') + local vol_name=$(basename "$vol") + echo "$((vol_size * 1024))|$vol|$vol_name" + done + fi + + # Common user directories + for dir in "$HOME" "$HOME/Downloads" "$HOME/Documents" "$HOME/Library"; do + if [[ -d "$dir" ]]; then + local dir_size=$(du -sk "$dir" 2>/dev/null | cut -f1) + local dir_name=$(echo "$dir" | sed "s|^$HOME|~|") + echo "$((dir_size * 1024))|$dir|$dir_name" + fi + done + } | sort -t'|' -k1 -rn > "$temp_volumes" + + # Setup alternate screen + tput smcup 2>/dev/null || true + printf "\033[?25l" # Hide cursor + + cleanup_volumes() { + printf "\033[?25h" # Show cursor + tput rmcup 2>/dev/null || true + } + trap cleanup_volumes EXIT INT TERM + + local cursor=0 + local total_items=$(wc -l < "$temp_volumes" | tr -d ' ') + + while true; do + # Drain burst input (trackpad scroll -> many arrows) + type drain_pending_input >/dev/null 2>&1 && drain_pending_input + # Build output buffer to reduce flicker + local output="" + output+="\033[H\033[J" + output+=$'\n' + output+="\033[0;35m💾 Select a location to explore\033[0m"$'\n' + output+=$'\n' + + local idx=0 + while IFS='|' read -r size path display_name; do + local human_size=$(bytes_to_human "$size") + + # Determine icon + local icon="💾" + local color="${NC}" + if [[ "$path" == "/" ]]; then + icon="💿" + color="${BLUE}" + elif [[ "$path" == /Volumes/* ]]; then + icon="🔌" + color="${YELLOW}" + elif [[ "$path" == "$HOME" ]]; then + icon="🏠" + color="${GREEN}" + elif [[ "$path" == *"/Library" ]]; then + icon="📚" + color="${GRAY}" + else + icon="📁" + fi + + # Build line + local line="" + if [[ $idx -eq $cursor ]]; then + line=$(printf " ${GREEN}▶${NC} ${color}%-4s %-10s %s${NC}" "$icon" "$human_size" "$display_name") + else + line=$(printf " ${color}%-4s %-10s %s${NC}" "$icon" "$human_size" "$display_name") + fi + output+="$line"$'\n' + + ((idx++)) + done < "$temp_volumes" + + output+=$'\n' + + # Output everything at once + printf "%b" "$output" >&2 + + # Read key (suppress any escape sequences that might leak) + local key + key=$(read_key 2>/dev/null || echo "OTHER") + + case "$key" in + "UP") + ((cursor > 0)) && ((cursor--)) + ;; + "DOWN") + ((cursor < total_items - 1)) && ((cursor++)) + ;; + "ENTER"|"RIGHT") + # Get selected path and enter it + local selected_path="" + idx=0 + while IFS='|' read -r size path display_name; do + if [[ $idx -eq $cursor ]]; then + selected_path="$path" + break + fi + ((idx++)) + done < "$temp_volumes" + + if [[ -n "$selected_path" ]] && [[ -d "$selected_path" ]]; then + cleanup_volumes + trap - EXIT INT TERM + interactive_drill_down "$selected_path" "" + return + fi + ;; + "LEFT") + # In volumes view, LEFT does nothing (already at top level) + # User must press q/ESC to quit + ;; + "QUIT"|"q") + # Quit the volumes view + break + ;; + esac + done + + cleanup_volumes + trap - EXIT INT TERM +} + +# Interactive drill-down mode +interactive_drill_down() { + local start_path="$1" + local initial_items="${2:-}" # Pre-scanned items for first level + local current_path="$start_path" + local path_stack=() + local cursor=0 + local scroll_offset=0 # New: for scrolling + local need_scan=true + local wait_for_calc=false # Don't wait on first load, let user press 'r' + local temp_items="$TEMP_PREFIX.items" + + # Cache variables to avoid recalculation + local -a items=() + local has_calculating=false + local total_items=0 + + # Directory cache: store scan results for each visited directory + # Use temp files because bash 3.2 doesn't have associative arrays + local cache_dir="$TEMP_PREFIX.cache.$$" + mkdir -p "$cache_dir" 2>/dev/null || true + + # Setup alternate screen and hide cursor + tput smcup 2>/dev/null || true # Enter alternate screen + printf "\033[?25l" # Hide cursor + + # Save terminal settings and disable echo + local old_tty_settings="" + if [[ -t 0 ]]; then + old_tty_settings=$(stty -g 2>/dev/null || echo "") + stty -echo 2>/dev/null || true + fi + + # Cleanup on exit + cleanup_drill_down() { + # Restore terminal settings + if [[ -n "${old_tty_settings:-}" ]]; then + stty "$old_tty_settings" 2>/dev/null || true + fi + printf "\033[?25h" # Show cursor + tput rmcup 2>/dev/null || true # Exit alternate screen + [[ -d "${cache_dir:-}" ]] && rm -rf "$cache_dir" 2>/dev/null || true # Clean up cache + } + trap cleanup_drill_down EXIT INT TERM + + # Drain any input that accumulated before entering interactive mode + type drain_pending_input >/dev/null 2>&1 && drain_pending_input + + while true; do + # Drain any burst input (e.g. trackpad scroll converted to many arrow keys) + type drain_pending_input >/dev/null 2>&1 && drain_pending_input + # Only scan if needed (directory changed or refresh requested) + if [[ "$need_scan" == "true" ]]; then + # Generate cache key (use md5 hash of path) + local cache_key=$(echo "$current_path" | md5 2>/dev/null || echo "$current_path" | shasum | cut -d' ' -f1) + local cache_file="$cache_dir/$cache_key" + + # Check if we have cached results for this directory + if [[ -f "$cache_file" ]] && [[ "$wait_for_calc" != "true" ]]; then + # Load from cache (instant!) + cp "$cache_file" "$temp_items" + else + # Fast scan: load more items for scrolling (top 50) + # Note: scan function will handle screen clearing and progress display + # Use || true to prevent exit on scan failure + scan_directory_contents_fast "$current_path" "$temp_items" 50 true || { + # Scan failed - create empty result file + > "$temp_items" + } + + # Save to cache for next time (only if not empty) + if [[ -s "$temp_items" ]]; then + cp "$temp_items" "$cache_file" 2>/dev/null || true + fi + fi + + # Load items into array + items=() + if [[ -f "$temp_items" ]] && [[ -s "$temp_items" ]]; then + while IFS='|' read -r size type path; do + items+=("$size|$type|$path") + done < "$temp_items" + fi + total_items=${#items[@]} + + # No more calculating state + has_calculating=false + need_scan=false + wait_for_calc=false + + # Reset scroll when entering new directory + scroll_offset=0 + + # Drain any input accumulated during scanning + type drain_pending_input >/dev/null 2>&1 && drain_pending_input + + # Check if empty or scan failed + if [[ $total_items -eq 0 ]]; then + # Check if directory actually exists and is readable + if [[ ! -d "$current_path" ]] || [[ ! -r "$current_path" ]]; then + # Directory doesn't exist or can't read - show error + printf "\033[H\033[J" >&2 + echo "" >&2 + echo " ${RED}Error: Cannot access directory${NC}" >&2 + echo " ${GRAY}Path: $current_path${NC}" >&2 + echo "" >&2 + echo " ${GRAY}Press any key to go back...${NC}" >&2 + read_key >/dev/null 2>&1 + else + # Directory exists but scan returned nothing (timeout or empty) + printf "\033[H\033[J" >&2 + echo "" >&2 + echo " ${YELLOW}Empty directory or scan timeout${NC}" >&2 + echo " ${GRAY}Path: $current_path${NC}" >&2 + echo "" >&2 + echo " ${GRAY}Press ${NC}${GREEN}R${NC}${GRAY} to retry, any other key to go back${NC}" >&2 + + local retry_key + retry_key=$(read_key 2>/dev/null || echo "OTHER") + + if [[ "$retry_key" == "RETRY" ]]; then + # Retry scan + need_scan=true + continue + fi + fi + + # Go back to parent + if [[ ${#path_stack[@]} -gt 0 ]]; then + # Use bash 3.2 compatible way to get last element + local stack_size=${#path_stack[@]} + local last_index=$((stack_size - 1)) + current_path="${path_stack[$last_index]}" + unset "path_stack[$last_index]" + cursor=0 + need_scan=true + continue + else + # Can't go back further, just stay and show empty view + # Add a dummy item so the interface doesn't break + items=("0|dir|$current_path") + total_items=1 + fi + fi + fi + + # Build output buffer once for smooth rendering + local output="" + output+="\033[H\033[J" # Clear screen + output+=$'\n' + output+="\033[0;35m📊 Disk space explorer > $(echo "$current_path" | sed "s|^$HOME|~|")\033[0m"$'\n' + output+=$'\n' + + local max_show=15 # Show 15 items per page + local page_start=$scroll_offset + local page_end=$((scroll_offset + max_show)) + [[ $page_end -gt $total_items ]] && page_end=$total_items + + local display_idx=0 + local idx=0 + for item_info in "${items[@]}"; do + # Skip items before current page + if [[ $idx -lt $page_start ]]; then + ((idx++)) + continue + fi + + # Stop if we've shown enough items for this page + if [[ $idx -ge $page_end ]]; then + break + fi + + local size="${item_info%%|*}" + local rest="${item_info#*|}" + local type="${rest%%|*}" + local path="${rest#*|}" + local name=$(basename "$path") + + local human_size + if [[ "$size" -eq 0 ]]; then + human_size="0B" + else + human_size=$(bytes_to_human "$size") + fi + + # Get icon and color + local icon="" color="${NC}" + if [[ "$type" == "dir" ]]; then + icon="📁" color="${BLUE}" + if [[ $size -gt 10737418240 ]]; then color="${RED}" + elif [[ $size -gt 1073741824 ]]; then color="${YELLOW}" + fi + else + local ext="${name##*.}" + case "$ext" in + dmg|iso|pkg) icon="📦" ; color="${RED}" ;; + mov|mp4|avi|mkv|webm) icon="🎬" ; color="${YELLOW}" ;; + zip|tar|gz|rar|7z) icon="🗜️" ; color="${YELLOW}" ;; + pdf) icon="📄" ;; + jpg|jpeg|png|gif|heic) icon="🖼️" ;; + key|ppt|pptx) icon="📊" ;; + log) icon="📝" ; color="${GRAY}" ;; + *) icon="📄" ;; + esac + fi + + # Truncate name + if [[ ${#name} -gt 50 ]]; then name="${name:0:47}..."; fi + + # Build line with better spacing + # Icon (emoji) + 2 spaces + Right-aligned size + 4 spaces + filename + local line + if [[ $idx -eq $cursor ]]; then + line=$(printf " ${GREEN}▶${NC} ${color}%s %10s %s${NC}" "$icon" "$human_size" "$name") + else + line=$(printf " ${color}%s %10s %s${NC}" "$icon" "$human_size" "$name") + fi + output+="$line"$'\n' + + ((idx++)) + ((display_idx++)) + done + + output+=$'\n' + + # Show pagination info if there are more items + if [[ $total_items -gt $max_show ]]; then + local showing_end=$page_end + output+=" ${GRAY}Showing $((page_start + 1))-$showing_end of $total_items items${NC}"$'\n' + output+=$'\n' + fi + + # Bottom help bar + output+=" ${GRAY}↑/↓${NC} Navigate ${GRAY}|${NC} ${GRAY}Enter${NC} Open ${GRAY}|${NC} ${GRAY}←${NC} Back ${GRAY}|${NC} ${GRAY}Del${NC} Delete ${GRAY}|${NC} ${GRAY}Q/ESC${NC} Quit"$'\n' + + # Output everything at once (single write = no flicker) + printf "%b" "$output" >&2 + + # Drain any pending input to prevent escape sequence leakage + drain_pending_input 2>/dev/null || true + + # Read key (suppress any escape sequences that might leak) + local key + key=$(read_key 2>/dev/null || echo "OTHER") + + # Debug: uncomment to see what keys are being received + # printf "\rDEBUG: Received key=[%s] " "$key" >&2 + # sleep 1 + + case "$key" in + "UP") + # Move cursor up + if [[ $cursor -gt 0 ]]; then + ((cursor--)) + # Scroll up if cursor goes above visible area + if [[ $cursor -lt $scroll_offset ]]; then + scroll_offset=$cursor + fi + fi + ;; + "DOWN") + # Move cursor down + if [[ $cursor -lt $((total_items - 1)) ]]; then + ((cursor++)) + # Scroll down if cursor goes below visible area + local page_end=$((scroll_offset + max_show)) + if [[ $cursor -ge $page_end ]]; then + scroll_offset=$((cursor - max_show + 1)) + fi + fi + ;; + "ENTER"|"RIGHT") + # Enter selected item - directory or file + if [[ $cursor -lt ${#items[@]} ]]; then + local selected="${items[$cursor]}" + local size="${selected%%|*}" + local rest="${selected#*|}" + local type="${rest%%|*}" + local selected_path="${rest#*|}" + + if [[ "$type" == "dir" ]]; then + # Push current path to stack and enter the directory + path_stack+=("$current_path") + current_path="$selected_path" + cursor=0 + need_scan=true + else + # It's a file - open it for viewing + # Exit alternate screen temporarily + printf "\033[?25h" # Show cursor + tput rmcup 2>/dev/null || true + + # Try to open with system default viewer + local file_ext="${selected_path##*.}" + local open_success=false + + # For text-like files, use less + case "$file_ext" in + txt|log|md|json|xml|yaml|yml|conf|cfg|ini|sh|bash|zsh|py|js|ts|go|rs|c|cpp|h|java|rb|php|html|css|sql) + if command -v less &>/dev/null; then + less -F "$selected_path" 2>/dev/null && open_success=true + fi + ;; + *) + # For other files, try system open + if command -v open &>/dev/null; then + open "$selected_path" 2>/dev/null && open_success=true + sleep 0.5 # Give time for app to launch + fi + ;; + esac + + # If nothing worked, show a message + if [[ "$open_success" != "true" ]]; then + echo "" + echo " ${YELLOW}File: $selected_path${NC}" + echo " ${GRAY}Press any key to return...${NC}" + read -n 1 -s 2>/dev/null + fi + + # Return to alternate screen + tput smcup 2>/dev/null || true + printf "\033[?25l" # Hide cursor + fi + fi + ;; + "LEFT") + # Go back to parent directory with left arrow + if [[ ${#path_stack[@]} -gt 0 ]]; then + # Pop from stack and go back + # Use bash 3.2 compatible way to get last element + local stack_size=${#path_stack[@]} + local last_index=$((stack_size - 1)) + current_path="${path_stack[$last_index]}" + unset "path_stack[$last_index]" + cursor=0 + need_scan=true + else + # Already at root/start path - do nothing (don't quit) + : + fi + ;; + "DELETE") + # Delete selected item (file or directory) + if [[ $cursor -lt ${#items[@]} ]]; then + local selected="${items[$cursor]}" + local size="${selected%%|*}" + local rest="${selected#*|}" + local type="${rest%%|*}" + local selected_path="${rest#*|}" + local selected_name=$(basename "$selected_path") + local human_size=$(bytes_to_human "$size") + + # Check if sudo is needed + local needs_sudo=false + if [[ ! -w "$selected_path" ]] || [[ ! -w "$(dirname "$selected_path")" ]]; then + needs_sudo=true + fi + + # Build simple confirmation + printf "\033[H\033[J" + echo "" + echo "" + + if [[ "$type" == "dir" ]]; then + echo " ${RED}Delete folder? ${YELLOW}⚠️ This action cannot be undone!${NC}" + else + echo " ${RED}Delete file? ${YELLOW}⚠️ This action cannot be undone!${NC}" + fi + + echo "" + + # Show icon based on type + if [[ "$type" == "dir" ]]; then + echo " 📁 ${YELLOW}$selected_name${NC}" + else + local ext="${selected_name##*.}" + local icon="📄" + case "$ext" in + dmg|iso|pkg) icon="📦" ;; + mov|mp4|avi|mkv|webm) icon="🎬" ;; + zip|tar|gz|rar|7z) icon="🗜️" ;; + jpg|jpeg|png|gif|heic) icon="🖼️" ;; + esac + echo " $icon ${YELLOW}$selected_name${NC}" + fi + + echo " ${GRAY}Size: $human_size${NC}" + echo " ${GRAY}Path: $selected_path${NC}" + + if [[ "$needs_sudo" == "true" ]]; then + echo "" + echo " ${YELLOW}🔐 Requires admin privileges${NC}" + fi + + echo "" + echo " ${GRAY}Press ${NC}${GREEN}ENTER${NC}${GRAY} to confirm, ${NC}${YELLOW}ESC/Q${NC}${GRAY} to cancel${NC}" + + # Read confirmation + local confirm + confirm=$(read_key 2>/dev/null || echo "QUIT") + + if [[ "$confirm" == "ENTER" ]]; then + # Show deleting message + printf "\033[H\033[J" + echo "" + echo " ${BLUE}🗑️ Deleting...${NC}" + echo "" + + # Try to delete with sudo if needed + local delete_success=false + if [[ "$needs_sudo" == "true" ]]; then + if sudo rm -rf "$selected_path" 2>/dev/null; then + delete_success=true + fi + else + if rm -rf "$selected_path" 2>/dev/null; then + delete_success=true + fi + fi + + if [[ "$delete_success" == "true" ]]; then + echo " ${GREEN}✓ Deleted successfully${NC}" + echo " ${GRAY}Freed: $human_size${NC}" + sleep 0.8 + + # Clear cache to force rescan + local cache_key=$(echo "$current_path" | md5 2>/dev/null || echo "$current_path" | shasum | cut -d' ' -f1) + local cache_file="$cache_dir/$cache_key" + rm -f "$cache_file" 2>/dev/null || true + + # Refresh the view + need_scan=true + + # Adjust cursor if needed + if [[ $cursor -ge $((total_items - 1)) ]] && [[ $cursor -gt 0 ]]; then + ((cursor--)) + fi + else + echo " ${RED}✗ Failed to delete${NC}" + echo "" + echo " ${YELLOW}Possible reasons:${NC}" + echo " • File is being used by another application" + echo " • Insufficient permissions" + echo " • System protection (SIP) prevents deletion" + echo "" + echo " ${GRAY}Press any key to continue...${NC}" + read_key >/dev/null 2>&1 + fi + fi + fi + ;; + "QUIT"|"q") + # Quit the explorer + break + ;; + *) + # Unknown key - ignore it + ;; + esac + done + + # Cleanup is handled by trap +} + +# Main interactive loop +interactive_mode() { + CURSOR_POS=0 + VIEW_MODE="overview" + + while true; do + type drain_pending_input >/dev/null 2>&1 && drain_pending_input + display_interactive_menu + + local key=$(read_key) + case "$key" in + "QUIT") + break + ;; + "UP") + if [[ "$VIEW_MODE" == "navigate" ]]; then + ((CURSOR_POS > 0)) && ((CURSOR_POS--)) + fi + ;; + "DOWN") + if [[ "$VIEW_MODE" == "navigate" ]]; then + local max_count=$(count_directories) + ((CURSOR_POS < max_count - 1)) && ((CURSOR_POS++)) + fi + ;; + "RIGHT") + if [[ "$VIEW_MODE" == "navigate" ]]; then + # Enter selected directory + local selected_path=$(get_path_at_cursor "$CURSOR_POS") + if [[ -n "$selected_path" ]] && [[ -d "$selected_path" ]]; then + CURRENT_PATH="$selected_path" + CURSOR_POS=0 + perform_scan "$CURRENT_PATH" + fi + else + # Enter navigation mode + VIEW_MODE="navigate" + CURSOR_POS=0 + fi + ;; + "LEFT") + if [[ "$VIEW_MODE" == "navigate" ]]; then + # Go back to parent + if [[ "$CURRENT_PATH" != "$HOME" ]] && [[ "$CURRENT_PATH" != "/" ]]; then + CURRENT_PATH="$(dirname "$CURRENT_PATH")" + CURSOR_POS=0 + perform_scan "$CURRENT_PATH" + fi + else + VIEW_MODE="overview" + fi + ;; + "f"|"F") + VIEW_MODE="files" + ;; + "t"|"T") + VIEW_MODE="types" + ;; + "ENTER") + if [[ "$VIEW_MODE" == "navigate" ]]; then + # Same as RIGHT + local selected_path=$(get_path_at_cursor "$CURSOR_POS") + if [[ -n "$selected_path" ]] && [[ -d "$selected_path" ]]; then + CURRENT_PATH="$selected_path" + CURSOR_POS=0 + perform_scan "$CURRENT_PATH" + fi + else + break + fi + ;; + *) + # Any other key in overview mode exits + if [[ "$VIEW_MODE" == "overview" ]]; then + break + fi + ;; + esac + done +} + +# ============================================================================ +# Main Entry Point +# ============================================================================ + +# Export results to CSV +export_to_csv() { + local output_file="$1" + local temp_dirs="$TEMP_PREFIX.dirs" + + if [[ ! -f "$temp_dirs" ]]; then + log_error "No scan data available to export" + return 1 + fi + + { + echo "Size (Bytes),Size (Human),Path" + while IFS='|' read -r size path; do + local human=$(bytes_to_human "$size") + echo "$size,\"$human\",\"$path\"" + done < "$temp_dirs" + } > "$output_file" + + log_success "Exported to $output_file" +} + +# Export results to JSON +export_to_json() { + local output_file="$1" + local temp_dirs="$TEMP_PREFIX.dirs" + local temp_large="$TEMP_PREFIX.large" + + if [[ ! -f "$temp_dirs" ]]; then + log_error "No scan data available to export" + return 1 + fi + + { + echo "{" + echo " \"scan_date\": \"$(date -u +"%Y-%m-%dT%H:%M:%SZ")\"," + echo " \"target_path\": \"$CURRENT_PATH\"," + echo " \"directories\": [" + + local first=true + while IFS='|' read -r size path; do + [[ "$first" == "false" ]] && echo "," + first=false + local human=$(bytes_to_human "$size") + printf ' {"size": %d, "size_human": "%s", "path": "%s"}' "$size" "$human" "$path" + done < "$temp_dirs" + + echo "" + echo " ]," + echo " \"large_files\": [" + + if [[ -f "$temp_large" ]]; then + first=true + while IFS='|' read -r size path; do + [[ "$first" == "false" ]] && echo "," + first=false + local human=$(bytes_to_human "$size") + printf ' {"size": %d, "size_human": "%s", "path": "%s"}' "$size" "$human" "$path" + done < "$temp_large" + echo "" + fi + + echo " ]" + echo "}" + } > "$output_file" + + log_success "Exported to $output_file" +} + +main() { + local target_path="$HOME" + + # Parse arguments - only support --help + while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + echo "Usage: mole analyze" + echo "" + echo "Interactive disk space explorer - Navigate folders sorted by size" + echo "" + echo "Keyboard Controls:" + echo " ↑/↓ Navigate items" + echo " Enter / → Open selected folder" + echo " ← Go back to parent directory" + echo " Delete Delete selected file/folder (requires confirmation)" + echo " Q / ESC Quit the explorer" + echo "" + echo "Features:" + echo " • Files and folders sorted by size (largest first)" + echo " • Shows top 16 items per directory" + echo " • Fast parallel scanning with smart timeout" + echo " • Session cache for instant navigation" + echo " • Color coding for large folders (Red >10GB, Yellow >1GB)" + echo " • Safe deletion with confirmation" + echo "" + echo "Examples:" + echo " mole analyze Start exploring from home directory" + echo "" + exit 0 + ;; + -*) + echo "Error: Unknown option: $1" >&2 + echo "Usage: mole analyze" >&2 + echo "Use 'mole analyze --help' for more information" >&2 + exit 1 + ;; + *) + echo "Error: Paths are not supported in beta version" >&2 + echo "Usage: mole analyze" >&2 + echo "The explorer will start from your home directory" >&2 + exit 1 + ;; + esac + done + + CURRENT_PATH="$target_path" + + # Create cache directory + mkdir -p "$CACHE_DIR" 2>/dev/null || true + + # Start interactive drill-down mode (no volumes view, no export) + interactive_drill_down "$target_path" "" +} + +# Run if executed directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/lib/common.sh b/lib/common.sh index fdbbc6b..4a30971 100755 --- a/lib/common.sh +++ b/lib/common.sh @@ -11,6 +11,7 @@ readonly BLUE="${ESC}[0;34m" readonly YELLOW="${ESC}[1;33m" readonly PURPLE="${ESC}[0;35m" readonly RED="${ESC}[0;31m" +readonly GRAY="${ESC}[0;90m" readonly NC="${ESC}[0m" # Logging configuration @@ -104,17 +105,49 @@ read_key() { 'q'|'Q') echo "QUIT" ;; 'a'|'A') echo "ALL" ;; 'n'|'N') echo "NONE" ;; + 'd'|'D') echo "DELETE" ;; + 'r'|'R') echo "RETRY" ;; '?') echo "HELP" ;; + $'\x7f'|$'\x08') echo "DELETE" ;; # Delete key (labeled "delete" on Mac, actually backspace) $'\x1b') - # Read the next two bytes within 1s; works well on macOS bash 3.2 - 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 "OTHER" ;; - esac + # ESC sequence - could be arrow key, delete key, or ESC alone + # Read the next two bytes within 1s + if IFS= read -r -s -n 1 -t 1 rest 2>/dev/null; then + if [[ "$rest" == "[" ]]; then + # Got ESC [, read next character + if IFS= read -r -s -n 1 -t 1 rest2 2>/dev/null; then + case "$rest2" in + "A") echo "UP" ;; + "B") echo "DOWN" ;; + "C") echo "RIGHT" ;; + "D") echo "LEFT" ;; + "3") + # Delete key (Fn+Delete): ESC [ 3 ~ + IFS= read -r -s -n 1 -t 1 rest3 2>/dev/null + if [[ "$rest3" == "~" ]]; then + echo "DELETE" + else + echo "OTHER" + fi + ;; + "5") + # Page Up key: ESC [ 5 ~ + IFS= read -r -s -n 1 -t 1 rest3 2>/dev/null + [[ "$rest3" == "~" ]] && echo "OTHER" || echo "OTHER" + ;; + "6") + # Page Down key: ESC [ 6 ~ + IFS= read -r -s -n 1 -t 1 rest3 2>/dev/null + [[ "$rest3" == "~" ]] && echo "OTHER" || echo "OTHER" + ;; + *) echo "OTHER" ;; + esac + else + echo "QUIT" # ESC [ timeout + fi + else + echo "QUIT" # ESC + something else + fi else # ESC pressed alone - treat as quit echo "QUIT" @@ -124,6 +157,19 @@ read_key() { esac } +# Drain pending input (useful for scrolling prevention) +drain_pending_input() { + local dummy + local drained=0 + # Single pass with reasonable timeout + # Touchpad scrolling can generate bursts of arrow keys + while IFS= read -r -s -n 1 -t 0.001 dummy 2>/dev/null; do + ((drained++)) + # Safety limit to prevent infinite loop + [[ $drained -gt 500 ]] && break + done +} + # Menu display helper show_menu_option() { local number="$1" diff --git a/mole b/mole index 3fb7491..2fe567e 100755 --- a/mole +++ b/mole @@ -4,11 +4,13 @@ # # 🧹 Clean - Remove junk files and optimize system # 🗑️ Uninstall - Remove applications completely +# 📊 Analyze - Interactive disk space explorer # # Usage: # ./mole # Interactive main menu # ./mole clean # Direct clean mode # ./mole uninstall # Direct uninstall mode +# ./mole analyze # Disk space explorer # ./mole --help # Show help set -euo pipefail @@ -129,6 +131,7 @@ show_help() { printf " %s%-28s%s %s\n" "$GREEN" "mole clean" "$NC" "Deeper system cleanup" printf " %s%-28s%s %s\n" "$GREEN" "mole clean --dry-run" "$NC" "Preview cleanup (no deletions)" printf " %s%-28s%s %s\n" "$GREEN" "mole uninstall" "$NC" "Remove applications completely" + printf " %s%-28s%s %s\n" "$GREEN" "mole analyze" "$NC" "Interactive disk space explorer" printf " %s%-28s%s %s\n" "$GREEN" "mole update" "$NC" "Update Mole to the latest version" printf " %s%-28s%s %s\n" "$GREEN" "mole --version" "$NC" "Show installed version" printf " %s%-28s%s %s\n" "$GREEN" "mole --help" "$NC" "Show this help message" @@ -213,14 +216,15 @@ show_main_menu() { printf '\033[u\033[0J' fi - show_menu_option 1 "Clean System - Remove junk files and optimize" "$([[ $selected -eq 1 ]] && echo true || echo false)" + show_menu_option 1 "Clean Mac - Remove junk files and optimize" "$([[ $selected -eq 1 ]] && echo true || echo false)" show_menu_option 2 "Uninstall Apps - Remove applications completely" "$([[ $selected -eq 2 ]] && echo true || echo false)" - show_menu_option 3 "Help & Information - Usage guide and tips" "$([[ $selected -eq 3 ]] && echo true || echo false)" - show_menu_option 4 "Exit - Close Mole" "$([[ $selected -eq 4 ]] && echo true || echo false)" + show_menu_option 3 "Analyze Disk - Interactive space explorer" "$([[ $selected -eq 3 ]] && echo true || echo false)" + show_menu_option 4 "Help & Information - Usage guide and tips" "$([[ $selected -eq 4 ]] && echo true || echo false)" + show_menu_option 5 "Exit - Close Mole" "$([[ $selected -eq 5 ]] && echo true || echo false)" if [[ -t 0 ]]; then echo "" - echo -e "${BLUE}↑/↓ to navigate, ENTER to select, Q to quit${NC}" + echo -e " ${GRAY}↑/↓${NC} Navigate ${GRAY}|${NC} ${GRAY}Enter${NC} Select ${GRAY}|${NC} ${GRAY}Q/ESC${NC} Quit" fi } @@ -260,7 +264,7 @@ interactive_main_menu() { case "$key" in "UP") ((current_option > 1)) && ((current_option--)) ;; - "DOWN") ((current_option < 4)) && ((current_option++)) ;; + "DOWN") ((current_option < 5)) && ((current_option++)) ;; "ENTER"|"$current_option") show_cursor case $current_option in @@ -268,20 +272,22 @@ interactive_main_menu() { exec "$SCRIPT_DIR/bin/clean.sh" ;; 2) exec "$SCRIPT_DIR/bin/uninstall.sh" ;; - 3) clear; show_help; exit 0 ;; - 4) cleanup_and_exit ;; + 3) exec "$SCRIPT_DIR/bin/analyze.sh" ;; + 4) clear; show_help; exit 0 ;; + 5) cleanup_and_exit ;; esac ;; "QUIT") cleanup_and_exit ;; - [1-4]) + [1-5]) show_cursor case $key in 1) exec "$SCRIPT_DIR/bin/clean.sh" ;; 2) exec "$SCRIPT_DIR/bin/uninstall.sh" ;; - 3) clear; show_help; exit 0 ;; - 4) cleanup_and_exit ;; + 3) exec "$SCRIPT_DIR/bin/analyze.sh" ;; + 4) clear; show_help; exit 0 ;; + 5) cleanup_and_exit ;; esac ;; esac @@ -296,6 +302,9 @@ main() { "uninstall") exec "$SCRIPT_DIR/bin/uninstall.sh" ;; + "analyze") + exec "$SCRIPT_DIR/bin/analyze.sh" "${@:2}" + ;; "update") update_mole exit 0