From b07a2ace4a097ca65dd8e9539d38262acf0997c3 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 3 Oct 2025 11:13:39 +0800 Subject: [PATCH] :pencil: Improve documentation --- README.md | 97 +-- bin/analyze.sh | 1961 ------------------------------------------------ mole | 2 +- 3 files changed, 12 insertions(+), 2048 deletions(-) delete mode 100755 bin/analyze.sh diff --git a/README.md b/README.md index 18be40f..eb7a6cd 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ - 🦑 **Deep System Cleanup** - Remove hidden caches, logs, and temp files in one sweep - πŸ“¦ **Smart Uninstall** - Complete app removal with all related files and folders -- πŸ“Š **Disk Space Analyzer** - Visualize disk usage with lightning-fast mdfind + du hybrid scanning - ⚑️ **Fast Interactive UI** - Arrow-key navigation with pagination for large lists - 🧹 **Massive Space Recovery** - Reclaim 100GB+ of wasted disk space @@ -21,12 +20,11 @@ curl -fsSL https://raw.githubusercontent.com/tw93/mole/main/install.sh | bash ## Usage ```bash -mole # Interactive main menu -mole clean # Deep system cleanup +mole # Interactive main menu +mole clean # Deep system cleanup mole clean --dry-run # Preview cleanup (no deletions) -mole uninstall # Interactive app uninstaller -mole analyze [path]# Analyze disk space (default: home directory) -mole --help # Show help +mole uninstall # Interactive app uninstaller +mole --help # Show help ``` ## Examples @@ -86,86 +84,6 @@ Select Apps to Remove ==================================================================== ``` -### Disk Space Analyzer - -```bash -# Quick start - explore your home directory -$ mole analyze - -# View all disk volumes and major locations -$ mole analyze --all - -πŸ’Ύ Disk Volumes & Locations - - TYPE SIZE LOCATION - ──────────────────────────────────────────────────────────────────────────────── -β–Ά πŸ’Ώ 245.3GB Macintosh HD (Root) - 🏠 89.2GB ~ - πŸ“š 45.1GB ~/Library - πŸ“ 33.7GB ~/Downloads - πŸ“ 18.4GB ~/Documents - πŸ”Œ 128.0GB External Drive - -# Explore specific directory with progress bar -$ mole analyze ~/Downloads - - πŸ“Š [β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ] 100% (25/25) ← Real-time scanning progress - -πŸ“Š Disk Space Explorer - - Current: ~/Downloads - ↑/↓: Navigate | β†’ / Enter: Open folder | ← / Backspace: Back | q: Quit - - Items (sorted by size): - - - - TYPE SIZE NAME - ──────────────────────────────────────────────────────────────────────────────── - β–Ά πŸ“ 33.72GB materials ← Use arrow keys to select - πŸ“ 5.67GB learning - πŸ“ 4.50GB projects - 🎬 1.68GB recording.mov ← Files can't be opened - 🎬 1.58GB presentation.mov - πŸ“¦ 1.20GB OldInstaller.dmg - πŸ“ 2.22GB shared - πŸ“ 1.78GB recent - ... and 12 more items - -# Press Enter on "materials" folder to drill down: - -πŸ“Š Disk Space Explorer - - Current: ~/Downloads/materials - ↑/↓: Navigate | β†’ / Enter: Open folder | ← / Backspace: Back | q: Quit - - Items (sorted by size): - - β–Ά πŸ“ 15.2GB videos ← Keep drilling down - πŸ“ 10.1GB documents - πŸ“ 6.8GB images - 🎬 2.5GB demo.mov -``` - -**Interactive Navigation:** - -- **Instant startup** - no waiting for initial scan -- **Real-time progress** - visual progress bar when scanning (10+ directories) -- **All volumes view** - `--all` flag shows all disks and major locations -- **Files and folders mixed together**, sorted by size (largest first) -- Shows **top 16 items** per directory (largest items only) -- Use **↑/↓** arrow keys to navigate (green arrow β–Ά shows selection) -- Press **Enter** on a πŸ“ folder to drill down into it -- Press **Backspace** or **←** to go back to parent directory -- Press **q** to quit at any time -- **Color coding**: Red folders >10GB, Yellow >1GB, Blue <1GB -- Files (πŸ“¦πŸŽ¬πŸ“„πŸ–ΌοΈπŸ“Š) are shown but can't be opened (only folders) - -**Performance:** -- **Fast scanning** - real-time progress bar for large directories (10+ folders) -- **Smart caching** - sizes are calculated once and cached during navigation -- **Top 16 only** - shows largest items first, keeps interface clean and fast - ## What Mole Cleans | Category | Targets | Typical Recovery | @@ -175,6 +93,13 @@ $ mole analyze ~/Downloads | **Developer** | npm, pip, Docker, Homebrew, Xcode | 15-40GB | | **Apps** | Slack, Discord, Teams, Notion cache | 3-10GB | +**Protect Important Files:** Create `~/.config/mole/whitelist` to preserve critical caches: + +```bash +# Example: Protect Playwright browsers and build tools +echo '~/Library/Caches/ms-playwright*' >> ~/.config/mole/whitelist +``` + ## What Mole Uninstalls | Component | Files Removed | Examples | diff --git a/bin/analyze.sh b/bin/analyze.sh deleted file mode 100755 index c38512c..0000000 --- a/bin/analyze.sh +++ /dev/null @@ -1,1961 +0,0 @@ -#!/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 - # Drain burst input (trackpad scroll -> many arrows) - type drain_pending_input >/dev/null 2>&1 && drain_pending_input - # Build output buffer to reduce flicker - local output="" - output+="\033[H\033[J" - output+=$'\n' - output+="\033[0;35mβ–Ά πŸ’Ύ 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"|"RIGHT") - # 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"|"RIGHT") - # 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/mole b/mole index 71fd6f6..9a7829c 100755 --- a/mole +++ b/mole @@ -122,7 +122,7 @@ EOF else printf "%s\n" "${ground_color}${mole_lines[$idx]}${NC}" fi - sleep 0.16 + sleep 0.1 done printf '\n'