diff --git a/bin/analyze.sh b/bin/analyze.sh new file mode 100755 index 0000000..c9f57d0 --- /dev/null +++ b/bin/analyze.sh @@ -0,0 +1,1959 @@ +#!/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 +scan_directory_contents_fast() { + local dir_path="$1" + local output_file="$2" + local max_items="${3:-16}" + local show_progress="${4:-true}" + + local temp_all="$output_file.all" + + # Count items first for progress bar + local total_dirs=$(find "$dir_path" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l | tr -d ' ') + + local count=0 + local last_update=0 + + # Get directories and files with sizes in parallel (much faster!) + local temp_dirs="$output_file.dirs" + local temp_files="$output_file.files" + + # Parallel directory scanning using xargs (4 parallel jobs) + if [[ $total_dirs -gt 0 ]]; then + # Start parallel scan + find "$dir_path" -mindepth 1 -maxdepth 1 -type d -print0 2>/dev/null | \ + xargs -0 -n 1 -P 4 sh -c ' + size=$(du -sk "$1" 2>/dev/null | cut -f1 || echo 0) + echo "$((size * 1024))|dir|$1" + ' _ > "$temp_dirs" & + local du_pid=$! + + # Show progress while waiting + if [[ "$show_progress" == "true" ]] && [[ $total_dirs -gt 10 ]]; then + printf "\033[H\033[J" >&2 + echo "" >&2 + + local spinner=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') + local i=0 + while kill -0 "$du_pid" 2>/dev/null; do + # Count how many results we have so far + local completed=$(wc -l < "$temp_dirs" 2>/dev/null | tr -d ' ') + [[ -z "$completed" ]] && completed=0 + + printf "\r ${BLUE}📊 ${spinner[$((i % 10))]} Scanning: %d/%d completed${NC}" "$completed" "$total_dirs" >&2 + ((i++)) + sleep 0.15 + done + printf "\r\033[K" >&2 + fi + wait "$du_pid" + else + : > "$temp_dirs" + fi + + # Files: get actual size (fast, no need for parallel) + find "$dir_path" -mindepth 1 -maxdepth 1 -type f 2>/dev/null | while IFS= read -r item; do + local size=$(stat -f%z "$item" 2>/dev/null || echo "0") + echo "$size|file|$item" + done > "$temp_files" + + # Combine and sort + cat "$temp_dirs" "$temp_files" 2>/dev/null | sort -t'|' -k1 -rn | head -"$max_items" > "$output_file" + + # Cleanup + rm -f "$temp_dirs" "$temp_files" 2>/dev/null + + # Clear progress line if shown + if [[ "$show_progress" == "true" ]] && [[ $total_dirs -gt 10 ]]; then + printf "\r\033[K" >&2 + fi +} + +# 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 + # Build output buffer to reduce flicker + local output="" + output+="\033[H\033[J" + output+=$'\n' + output+="\033[0;35m▶ 💾 Disk Volumes & Locations\033[0m"$'\n' + output+=$'\n' + output+=" ${GRAY}Select a location to explore. ↑/↓: Navigate | → / Enter: Open | ← / q: Quit${NC}"$'\n' + output+=$'\n' + output+=" TYPE SIZE LOCATION"$'\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") + # Get selected path + 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 + ;; + "QUIT"|"q") + 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 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 + + # Setup alternate screen and hide cursor + tput smcup 2>/dev/null || true # Enter alternate screen + printf "\033[?25l" # Hide cursor + + # Cleanup on exit + cleanup_drill_down() { + printf "\033[?25h" # Show cursor + tput rmcup 2>/dev/null || true # Exit alternate screen + } + trap cleanup_drill_down EXIT INT TERM + + 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 + # Clear screen for scanning + printf "\033[H\033[J" >&2 + + # Fast scan: list items immediately (top 16 only) + scan_directory_contents_fast "$current_path" "$temp_items" 16 + + # 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 + + # Check if empty + if [[ $total_items -eq 0 ]]; then + # Empty directory - go back + printf "\033[H\033[J" >&2 + echo "" >&2 + echo " ${YELLOW}Empty directory${NC}" >&2 + echo "" >&2 + echo " ${GRAY}Press any key to go back...${NC}" >&2 + read_key >/dev/null 2>&1 + if [[ ${#path_stack[@]} -gt 0 ]]; then + current_path="${path_stack[-1]}" + unset 'path_stack[-1]' + cursor=0 + need_scan=true + continue + else + break + 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\033[0m"$'\n' + output+=$'\n' + output+=" ${BLUE}Current:${NC} $(echo "$current_path" | sed "s|^$HOME|~|")"$'\n' + output+=" ${GRAY}↑/↓: Navigate | → / Enter: Open folder | ← / Backspace / q: Back | q: Quit${NC}"$'\n' + output+=$'\n' + output+=" ${YELLOW}Items (sorted by size):${NC}"$'\n' + output+=$'\n' + output+=" TYPE SIZE NAME"$'\n' + output+=" ────────────────────────────────────────────────────────────────────────────────"$'\n' + + local max_show=16 + local idx=0 + for item_info in "${items[@]}"; do + [[ $idx -ge $max_show ]] && break + + 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 55 ]]; then name="${name:0:52}..."; fi + + # Build line + local line + if [[ $idx -eq $cursor ]]; then + line=$(printf " ${GREEN}▶${NC} ${color}%-4s %-10s %s${NC}" "$icon" "$human_size" "$name") + else + line=$(printf " ${color}%-4s %-10s %s${NC}" "$icon" "$human_size" "$name") + fi + output+="$line"$'\n' + + ((idx++)) + done + + output+=$'\n' + + # Output everything at once (single write = no flicker) + 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") + local max_cursor=$(( total_items < max_show ? total_items - 1 : max_show - 1 )) + ((cursor < max_cursor)) && ((cursor++)) + ;; + "ENTER") + # Enter selected item (only if it's a directory) + 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 + path_stack+=("$current_path") + current_path="$selected_path" + cursor=0 + need_scan=true + fi + fi + ;; + "BACKSPACE"|"LEFT") + # Go back + if [[ ${#path_stack[@]} -gt 0 ]]; then + current_path="${path_stack[-1]}" + unset 'path_stack[-1]' + cursor=0 + need_scan=true + else + break + fi + ;; + "QUIT"|"q") + break + ;; + "r"|"R"|"SPACE") + # Refresh: re-scan current directory + need_scan=true + wait_for_calc=true + ;; + 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" + local interactive=false + local export_format="" + local export_file="" + local show_volumes=false + + # Parse arguments + while [[ $# -gt 0 ]]; do + case "$1" in + -i|--interactive) + interactive=true + shift + ;; + -a|--all) + show_volumes=true + shift + ;; + -e|--export) + export_format="$2" + export_file="${3:-disk_analysis_$(date +%Y%m%d_%H%M%S).$export_format}" + shift 2 + [[ $# -gt 0 ]] && shift + ;; + -h|--help) + echo "Usage: mole analyze [options] [path]" + echo "" + echo "Interactive disk space explorer - navigate like a file manager, sorted by size." + echo "" + echo "Options:" + echo " -a, --all Start with all volumes view (/, /Volumes/*)" + echo " -i, --interactive Use old interactive mode (legacy)" + echo " -h, --help Show this help" + echo "" + echo "Examples:" + echo " mole analyze # Explore home directory" + echo " mole analyze --all # Start with all disk volumes" + echo " mole analyze ~/Downloads # Explore Downloads" + echo " mole analyze ~/Library # Check system caches" + echo "" + echo "Features:" + echo " • Files and folders mixed together, sorted by size (largest first)" + echo " • Shows top 16 items per directory (largest items only)" + echo " • Use ↑/↓ to navigate, Enter to open folders, Backspace to go back" + echo " • Files (📦🎬📄) shown but can't be opened, only folders (📁) can" + echo " • Color coding: Red folders >10GB, Yellow >1GB, installers/videos highlighted" + echo " • Press q to quit at any time" + exit 0 + ;; + -*) + log_error "Unknown option: $1" + exit 1 + ;; + *) + target_path="$1" + shift + ;; + esac + done + + # Validate path + if [[ ! -d "$target_path" ]]; then + log_error "Invalid path: $target_path" + exit 1 + fi + + CURRENT_PATH="$target_path" + + # Create cache directory + mkdir -p "$CACHE_DIR" 2>/dev/null || true + + # Handle export if requested (requires scan) + if [[ -n "$export_format" ]]; then + # Check for mdfind + if ! command -v mdfind &>/dev/null; then + log_warning "mdfind not available, falling back to slower scan method" + fi + + perform_scan "$target_path" + + case "$export_format" in + csv) + export_to_csv "$export_file" + exit 0 + ;; + json) + export_to_json "$export_file" + exit 0 + ;; + *) + log_error "Unknown export format: $export_format (use csv or json)" + exit 1 + ;; + esac + fi + + if [[ "$interactive" == "true" ]]; then + # Old interactive mode (keep for compatibility) + if ! command -v mdfind &>/dev/null; then + log_warning "mdfind not available, falling back to slower scan method" + fi + perform_scan "$target_path" + interactive_mode + else + # Show volumes view if requested + if [[ "$show_volumes" == "true" ]]; then + show_volumes_overview + else + # New default: directly enter interactive drill-down mode (NO initial scan!) + interactive_drill_down "$target_path" "" + fi + fi +} + +# Run if executed directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi \ No newline at end of file diff --git a/lib/common.sh b/lib/common.sh index b869fa2..3874478 100755 --- a/lib/common.sh +++ b/lib/common.sh @@ -134,6 +134,11 @@ read_key() { *) echo "OTHER" ;; esac } +# Drain any pending input bytes (used to swallow rapid trackpad scroll sequences) +drain_pending_input() { + while IFS= read -r -s -t 0 -n 1 _; do :; done +} + # Menu display helper show_menu_option() {