#!/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[?25l\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 # Ensure cursor stays hidden after clearing spinner printf "\033[?25l" >&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 most useful locations (quick display, no size calculation) { # Priority order for display (prioritized by typical usefulness) [[ -d "$HOME" ]] && echo "1000|$HOME|🏠 Home Directory" [[ -d "$HOME/Downloads" ]] && echo "900|$HOME/Downloads|📥 Downloads" [[ -d "/Applications" ]] && echo "800|/Applications|📦 Applications" [[ -d "$HOME/Library" ]] && echo "700|$HOME/Library|📚 User Library" [[ -d "/Library" ]] && echo "600|/Library|📚 System Library" # External volumes (if any) if [[ -d "/Volumes" ]]; then local vol_priority=500 find /Volumes -mindepth 1 -maxdepth 1 -type d 2>/dev/null | while IFS= read -r vol; do local vol_name=$(basename "$vol") echo "$((vol_priority))|$vol|🔌 $vol_name" ((vol_priority--)) done fi } | sort -t'|' -k1 -rn > "$temp_volumes" # Setup alternate screen and hide cursor (keep hidden throughout) tput smcup 2>/dev/null || true printf "\033[?25l" >&2 # Hide cursor cleanup_volumes() { printf "\033[?25h" >&2 # Show cursor tput rmcup 2>/dev/null || true } trap cleanup_volumes EXIT INT TERM # Force cursor hidden at the start stty -echo 2>/dev/null || true local cursor=0 local total_items=$(wc -l < "$temp_volumes" | tr -d ' ') while true; do # Ensure cursor is always hidden printf "\033[?25l" >&2 # 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[?25l" # Hide cursor 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 priority path display_name; do # Build line (simple display without size) local line="" if [[ $idx -eq $cursor ]]; then line=$(printf " ${GREEN}▶${NC} ${BLUE}%s${NC}" "$display_name") else line=$(printf " ${GRAY}%s${NC}" "$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 priority 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 # Save cursor for potential return local saved_cursor=$cursor # Don't cleanup yet - stay in alternate screen trap - EXIT INT TERM # Enter drill-down, check return value if interactive_drill_down "$selected_path" ""; then # User quit (Q/ESC) - cleanup and exit cleanup_volumes return 0 else # User went back (LEFT at root) - return to menu # Restore trap trap cleanup_volumes EXIT INT TERM cursor=$saved_cursor # Just continue loop to redraw menu fi 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 # Note: We're already in alternate screen from show_volumes_overview # Just hide cursor, don't re-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 (but don't exit alternate screen - may return to menu) 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 # Don't call tput rmcup - we may be returning to volumes menu [[ -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 # Ensure cursor is always hidden during navigation printf "\033[?25l" >&2 # 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[?25l" # Hide cursor 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 # Read key directly without draining (to preserve all user input) 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 local file_ext="${selected_path##*.}" local filename=$(basename "$selected_path") local open_success=false # For text-like files, use less or fallback to open 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) # Clear screen and show loading message printf "\033[H\033[J" echo "" echo " ${BLUE}📄 Opening: $filename${NC}" echo "" # Try less first (best for text viewing) if command -v less &>/dev/null; then # Exit alternate screen only for less printf "\033[?25h" # Show cursor tput rmcup 2>/dev/null || true less -F "$selected_path" 2>/dev/null && open_success=true # Return to alternate screen tput smcup 2>/dev/null || true printf "\033[?25l" # Hide cursor else # Fallback to system open if less is not available echo " ${GRAY}Launching default application...${NC}" if command -v open &>/dev/null; then open "$selected_path" 2>/dev/null && open_success=true if [[ "$open_success" == "true" ]]; then echo "" echo " ${GREEN}✓${NC} File opened in external app" sleep 0.8 fi fi fi ;; *) # For other files, use system open (keep in alternate screen) # Show message without flashing printf "\033[H\033[J" echo "" echo " ${BLUE}📦 Opening: $filename${NC}" echo "" echo " ${GRAY}Launching default application...${NC}" if command -v open &>/dev/null; then open "$selected_path" 2>/dev/null && open_success=true # Show brief success message if [[ "$open_success" == "true" ]]; then echo "" echo " ${GREEN}✓${NC} File opened in external app" sleep 0.8 fi fi ;; esac # If nothing worked, show error message if [[ "$open_success" != "true" ]]; then printf "\033[H\033[J" echo "" echo " ${YELLOW}⚠️ Could not open file${NC}" echo "" echo " ${GRAY}File: $selected_path${NC}" echo " ${GRAY}Press any key to return...${NC}" read -n 1 -s 2>/dev/null fi 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 scroll_offset=0 need_scan=true else # Already at start path - return to volumes menu # Don't show cursor or exit screen - menu will handle it if [[ -n "${old_tty_settings:-}" ]]; then stty "$old_tty_settings" 2>/dev/null || true fi [[ -d "${cache_dir:-}" ]] && rm -rf "$cache_dir" 2>/dev/null || true trap - EXIT INT TERM return 1 # Return to menu 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 cleanup_drill_down trap - EXIT INT TERM return 0 # Return true to indicate normal exit ;; *) # Unknown key - ignore it ;; esac done # Cleanup is handled by trap return 0 # Normal exit if loop ends } # 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 with volumes overview to let user choose location show_volumes_overview } # Run if executed directly if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then main "$@" fi