diff --git a/.github/workflows/shell-quality-checks.yml b/.github/workflows/shell-quality-checks.yml index f09210e..ecef367 100644 --- a/.github/workflows/shell-quality-checks.yml +++ b/.github/workflows/shell-quality-checks.yml @@ -13,6 +13,11 @@ jobs: - name: Checkout source code uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.22.6' + - name: Install shell linting and testing tools run: brew install bats-core shfmt shellcheck @@ -21,3 +26,6 @@ jobs: - name: Run shellcheck linter and bats tests run: ./scripts/check.sh + + - name: Build Go disk analyzer + run: mkdir -p bin && go build -o bin/analyze-go ./cmd/analyze diff --git a/README.md b/README.md index c9444d4..aeec550 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ mo update # Update Mole mo remove # Remove Mole from system mo --help # Show help mo --version # Show installed version + ``` ## Tips @@ -152,6 +153,8 @@ Total: 156.8GB └─ šŸ“ Desktop 12.7GB ``` +> The analyzer now runs inside a Go/Bubble Tea TUI: use arrow keys + Enter to drill into folders, Backspace to go up, `r` to refresh, and `q` to quit. Large files and occupancy bars refresh after each scan so you can see the heaviest items immediately. + ## Support - If Mole reclaimed storage for you, consider starring the repo or sharing it with friends needing a cleaner Mac. diff --git a/analyze b/analyze new file mode 100755 index 0000000..e807230 Binary files /dev/null and b/analyze differ diff --git a/bin/analyze-go b/bin/analyze-go new file mode 100755 index 0000000..e807230 Binary files /dev/null and b/bin/analyze-go differ diff --git a/bin/analyze.sh b/bin/analyze.sh index 23807cb..a630e6f 100755 --- a/bin/analyze.sh +++ b/bin/analyze.sh @@ -1,2417 +1,13 @@ #!/bin/bash -# Mole - Disk Space Analyzer Module -# Fast disk analysis with mdfind + du hybrid approach +# Entry point for the Go-based disk analyzer binary bundled with Mole. set -euo pipefail -# Fix locale issues (avoid Perl warnings on non-English systems) -export LC_ALL=C -export LANG=C - -# 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="500000000" # 500MB (more sensitive) -readonly MIN_MEDIUM_FILE_SIZE="100000000" # 100MB - -# Emoji badges for list displays only -readonly BADGE_DIR="šŸž" -readonly BADGE_FILE="šŸš" -readonly BADGE_MEDIA="🄟" -readonly BADGE_BUNDLE="🄜" -readonly BADGE_LOG="šŸ¹" -readonly BADGE_APP="🐣" - -# Global state -declare SCAN_PID="" -declare CURRENT_PATH="$HOME" -declare CURRENT_DEPTH=1 - -# UI State -declare CURSOR_POS=0 -declare VIEW_MODE="overview" # overview, detail, files - -# Cleanup on exit -cleanup() { - show_cursor - # Cleanup temp files using glob pattern (analyze uses many temp files) - 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 - local file="" - while IFS= read -r file; do - if [[ -f "$file" ]]; then - local size - size=$(stat -f%z "$file" 2> /dev/null || echo "0") - echo "$size|$file" - fi - done < <(mdfind -onlyin "$target_path" "kMDItemFSSize > $MIN_LARGE_FILE_SIZE" 2> /dev/null) | - 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 - - local file="" - while IFS= read -r file; do - if [[ -f "$file" ]]; then - local size - size=$(stat -f%z "$file" 2> /dev/null || echo "0") - echo "$size|$file" - fi - done < <(mdfind -onlyin "$target_path" \ - "kMDItemFSSize > $MIN_MEDIUM_FILE_SIZE && kMDItemFSSize < $MIN_LARGE_FILE_SIZE" 2> /dev/null) | - 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 (increased parallelism) - find "$target_path" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null | - xargs -0 -P 8 -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 - 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 - 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 - 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 - spinner_chars="$(mo_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" - ) - 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 - filled=$((current * width / max)) - local empty - 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 - 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 - human_size=$(bytes_to_human "$size") - local filename - filename=$(basename "$path") - local dirname - dirname=$(basename "$(dirname "$path")") - - local info - info=$(get_file_info "$path") - local badge="${info%|*}" - printf " ${GREEN}%-8s${NC} %s %-40s ${GRAY}%s${NC}\n" \ - "$human_size" "$badge" "${filename:0:40}" "$dirname" - - ((count++)) - done < "$temp_large" - - echo "" - local total_human - 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 - human_size=$(bytes_to_human "$size") - local percentage - percentage=$(calc_percentage "$size" "$max_size") - local bar - bar=$(generate_bar "$size" "$max_size" 20) - local filename - filename=$(basename "$path") - local dirname - dirname=$(dirname "$path" | sed "s|^$HOME|~|") - - local info - info=$(get_file_info "$path") - local badge="${info%|*}" - printf " %s [${GREEN}%s${NC}] %7s\n" "$bar" "$human_size" "" - printf " %s %s\n" "$badge" "$filename" - printf " ${GRAY}%s${NC}\n\n" "$dirname" - - ((count++)) - done < "$temp_large" - - # Show total count - local total_count - 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 - human_size=$(bytes_to_human "$size") - local percentage - percentage=$(calc_percentage "$size" "$total_size") - local dirname - dirname=$(basename "$path") - - # Simple bar (10 chars) - local bar_width=10 - local percentage_int=${percentage%.*} # Remove decimal part - local filled - filled=$((percentage_int * bar_width / 100)) - [[ $filled -gt $bar_width ]] && filled=$bar_width - [[ $filled -lt 0 ]] && filled=0 - local empty - 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 %s\n" \ - "$human_size" "$bar" "$percentage" "$BADGE_DIR" "$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 - human_size=$(bytes_to_human "$size") - local percentage - percentage=$(calc_percentage "$size" "$total_size") - local bar - bar=$(generate_bar "$size" "$max_size" 20) - local display_path - display_path=$(echo "$path" | sed "s|^$HOME|~|") - local dirname - dirname=$(basename "$path") - - printf " %s [${BLUE}%s${NC}] %5s%%\n" "$bar" "$human_size" "$percentage" - printf " %s %s\n\n" "$BADGE_DIR" "$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 "High-concentration Hotspot Directories" - echo "" - - local count=0 - while IFS='|' read -r size path file_count; do - if [[ $count -ge 8 ]]; then - break - fi - - local human_size - human_size=$(bytes_to_human "$size") - local display_path - 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 - cache_size=$(du -sk "$HOME/Library/Caches" 2> /dev/null | cut -f1) - if [[ $cache_size -gt 1048576 ]]; then # > 1GB - local human - 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 - 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 - 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 - xcode_size=$(du -sk "$HOME/Library/Developer/Xcode/DerivedData" 2> /dev/null | cut -f1) - if [[ $xcode_size -gt 10485760 ]]; then - local xcode_human - 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 - 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}${ICON_NAV_RIGHT} Run${NC} ${YELLOW}mole clean${NC} ${GRAY}to cleanup system files${NC}" - else - echo " ${GRAY}${ICON_NAV_RIGHT} 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 - cache_size=$(du -sk "$HOME/Library/Caches" 2> /dev/null | cut -f1) - if [[ $cache_size -gt 1048576 ]]; then # > 1GB - local human - 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 - 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 - xcode_size=$(du -sk "$HOME/Library/Developer/Xcode/DerivedData" 2> /dev/null | cut -f1) - if [[ $xcode_size -gt 10485760 ]]; then # > 10GB - local human - 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 - backup_size=$(du -sk "$HOME/Library/Application Support/MobileSync/Backup" 2> /dev/null | cut -f1) - if [[ $backup_size -gt 5242880 ]]; then # > 5GB - local human - 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 - 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 " Tip: Run 'mole clean' to perform cleanup operations" - else - echo " ${GREEN}${ICON_SUCCESS}${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 - 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 - 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 badge="$BADGE_FILE" - local type="File" - - case "$ext" in - dmg | iso | pkg | zip | tar | gz | rar | 7z) - badge="$BADGE_BUNDLE" - type="Bundle" - ;; - mov | mp4 | avi | mkv | webm | jpg | jpeg | png | gif | heic) - badge="$BADGE_MEDIA" - type="Media" - ;; - pdf | key | ppt | pptx) - type="Document" - ;; - log) - badge="$BADGE_LOG" - type="Log" - ;; - app) - badge="$BADGE_APP" - type="App" - ;; - esac - - echo "$badge|$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 - mtime=$(stat -f%m "$path" 2> /dev/null || echo "0") - local now - now=$(date +%s) - local diff - diff=$((now - mtime)) - local days - 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 - months=$((days / 30)) - echo "${months}mo" - else - local years - 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 - human_size=$(bytes_to_human "$size") - local filename - filename=$(basename "$path") - local ext="${filename##*.}" - local age - age=$(get_file_age "$path") - - # Get file info and badge - local info - info=$(get_file_info "$path") - local badge="${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" "$badge" "$human_size" "$age" "$filename" - - ((count++)) - done < "$temp_large" - - local total - 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 - percentage=$((size * 100 / total_size)) - local bar_width - bar_width=$((percentage * chart_width / 100)) - [[ $bar_width -lt 1 ]] && bar_width=1 - - local dirname - dirname=$(basename "$path") - local human_size - 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 - 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 " ${ICON_NAV_RIGHT} Delete ${RED}[Can Delete]${NC} items (installers/DMG)" - echo " ${ICON_NAV_RIGHT} Review ${YELLOW}[Review]${NC} items (videos/archives)" - elif [[ "$CURRENT_PATH" == "$HOME/Library"* ]]; then - echo " ${ICON_NAV_RIGHT} Run ${GREEN}mole clean${NC} to clear caches safely" - echo " ${ICON_NAV_RIGHT} Check Xcode/developer caches if applicable" - else - echo " ${ICON_NAV_RIGHT} Review ${RED}[Can Delete]${NC} and ${YELLOW}[Review]${NC} items" - echo " ${ICON_NAV_RIGHT} 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 - percentage=$((size * 100 / total_size)) - local bar_width - bar_width=$((percentage * chart_width / 100)) - [[ $bar_width -lt 1 ]] && bar_width=1 - - local dirname - dirname=$(basename "$path") - local human_size - 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 - size=$(stat -f%z "$file" 2> /dev/null || echo "0") - local mtime - 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 - human_size=$(bytes_to_human "$size") - local filename - filename=$(basename "$path") - local dirname - dirname=$(dirname "$path" | sed "s|^$HOME|~|") - local days_ago - days_ago=$((($(date +%s) - mtime) / 86400)) - - local info - info=$(get_file_info "$path") - local badge="${info%|*}" - - printf " %s %s ${GRAY}(%s)${NC}\n" "$badge" "$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 - 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 - human_size=$(bytes_to_human "$size") - local percentage - percentage=$(calc_percentage "$size" "$total_size") - local bar - bar=$(generate_bar "$size" "$max_size" 20) - local display_path - display_path=$(echo "$path" | sed "s|^$HOME|~|") - local dirname - dirname=$(basename "$path") - - # Highlight selected line - if [[ $idx -eq $cursor_pos ]]; then - printf " ${BLUE}${ICON_ARROW}${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 - 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}${ICON_NAV_UP}${ICON_NAV_DOWN} Navigate | ${ICON_NAV_RIGHT} Drill Down | ${ICON_NAV_LEFT} 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() { - 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 (bash 3.2 compatible - no associative arrays) - local -a type_names=("Videos" "Images" "Archives" "Documents" "Audio") - - local type_name - for type_name in "${type_names[@]}"; do - local query="" - local badge="$BADGE_FILE" - - # Map type name to query and badge - case "$type_name" in - "Videos") - query="kMDItemContentType == 'public.movie' || kMDItemContentType == 'public.video'" - badge="$BADGE_MEDIA" - ;; - "Images") - query="kMDItemContentType == 'public.image'" - badge="$BADGE_MEDIA" - ;; - "Archives") - query="kMDItemContentType == 'public.archive' || kMDItemContentType == 'public.zip-archive'" - badge="$BADGE_BUNDLE" - ;; - "Documents") - query="kMDItemContentType == 'com.adobe.pdf' || kMDItemContentType == 'public.text'" - badge="$BADGE_FILE" - ;; - "Audio") - query="kMDItemContentType == 'public.audio'" - badge="šŸŽµ" - ;; - esac - - local files - files=$(mdfind -onlyin "$CURRENT_PATH" "$query" 2> /dev/null) - local count - 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 - 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 - human_size=$(bytes_to_human "$total_size") - printf " %s %-12s %8s (%d files)\n" "$badge" "$type_name:" "$human_size" "$count" - fi - fi - done - echo "" -} - -# Read a single key press -read_single_key() { - local key="" - # Read single character without waiting for Enter - if read -rsn1 key 2> /dev/null; then - echo "$key" - else - echo "q" - fi -} - -# Fast scan with progress display - optimized for speed -scan_directory_contents_fast() { - local dir_path="$1" - local output_file="$2" - local max_items="${3:-16}" - local show_progress="${4:-true}" - - # Auto-detect optimal parallel jobs using common function - local num_jobs - num_jobs=$(get_optimal_parallel_jobs "io") - # Keep within practical limits to avoid IO thrashing on small machines - if [[ $num_jobs -gt 32 ]]; then - num_jobs=32 - elif [[ $num_jobs -lt 4 ]]; then - num_jobs=4 - fi - - local temp_dirs="$output_file.dirs" - local temp_files="$output_file.files" - - # Show initial scanning message - if [[ "$show_progress" == "true" ]]; then - printf "\033[?25l\033[H\033[J" >&2 - echo "" >&2 - printf " ${BLUE} | Scanning...${NC}\r" >&2 - fi - - # Ultra-fast file scanning - batch stat for maximum speed - find "$dir_path" -mindepth 1 -maxdepth 1 -type f -print0 2> /dev/null | - xargs -0 -n 20 -P "$num_jobs" stat -f "%z|file|%N" 2> /dev/null > "$temp_files" & - local file_pid=$! - - # Smart directory scanning with aggressive optimization - # Strategy: Fast estimation first, accurate on-demand - find "$dir_path" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null | - xargs -0 -n 1 -P "$num_jobs" sh -c ' - dir="$1" - size="" - - # Ultra-fast strategy: Try du with 1 second timeout only - du -sk "$dir" 2>/dev/null > /tmp/mole_du_$$ & - du_pid=$! - - # Wait only 1 second (aggressive!) - if ! sleep 1 || kill -0 $du_pid 2>/dev/null; then - # Still running after 1s = large dir, kill it - kill -9 $du_pid 2>/dev/null || true - wait $du_pid 2>/dev/null || true - rm -f /tmp/mole_du_$$ 2>/dev/null - size="" - else - # Completed within 1s, use the result - size=$(cat /tmp/mole_du_$$ 2>/dev/null | cut -f1) - rm -f /tmp/mole_du_$$ 2>/dev/null - fi - - # If timeout or empty, use instant estimation - if [[ -z "$size" ]] || [[ "$size" -eq 0 ]]; then - # Ultra-fast: count only immediate files (no recursion) - # Use + instead of xargs for batch stat (much faster) - size=$(find "$dir" -type f -maxdepth 1 -print0 2>/dev/null | \ - xargs -0 stat -f%z 2>/dev/null | \ - awk "BEGIN{sum=0} {sum+=\$1} END{print int(sum/1024)}") - - # If still 0, mark as unknown but ensure it shows up - [[ -z "$size" ]] || [[ "$size" -eq 0 ]] && size=1 - fi - echo "$((size * 1024))|dir|$dir" - ' _ > "$temp_dirs" 2> /dev/null & - local dir_pid=$! - - # Show progress while waiting - if [[ "$show_progress" == "true" ]]; then - local -a spinner=() - if [[ -n "${MO_SPINNER_CHARS_ARRAY:-}" ]]; then - read -r -a spinner <<< "${MO_SPINNER_CHARS_ARRAY}" - else - local spinner_chars - spinner_chars="$(mo_spinner_chars)" - local chars_len=${#spinner_chars} - for ((idx = 0; idx < chars_len; idx++)); do - spinner+=("${spinner_chars:idx:1}") - done - fi - [[ ${#spinner[@]} -eq 0 ]] && spinner=('|' '/' '-' '\\') - local i=0 - local max_wait=45 # Balanced timeout (fast but not too aggressive) - local elapsed=0 - local tick=0 - local spin_len=${#spinner[@]} - ((spin_len == 0)) && spinner=('|' '/' '-' '\\') && spin_len=${#spinner[@]} - - while (kill -0 "$dir_pid" 2> /dev/null || kill -0 "$file_pid" 2> /dev/null); do - printf "\r ${BLUE}Scanning${NC} ${spinner[$((i % spin_len))]} (%ds)" "$elapsed" >&2 - ((i++)) - sleep 0.1 # Faster animation (100ms per frame) - ((tick++)) - - # Update elapsed seconds every 10 ticks (1 second) - if [[ $((tick % 10)) -eq 0 ]]; then - ((elapsed++)) - fi - - # Force kill if taking too long (30 seconds for fast response) - if [[ $elapsed -ge $max_wait ]]; then - kill -9 "$dir_pid" 2> /dev/null || true - kill -9 "$file_pid" 2> /dev/null || true - wait "$dir_pid" 2> /dev/null || true - wait "$file_pid" 2> /dev/null || true - printf "\r ${YELLOW}Large directory - showing estimated sizes${NC}\n" >&2 - sleep 0.3 - break - fi - done - printf "\r\033[K" >&2 - # Ensure cursor stays hidden after clearing spinner - printf "\033[?25l" >&2 - fi - - # Wait for completion (non-blocking if already killed) - wait "$file_pid" 2> /dev/null || true - wait "$dir_pid" 2> /dev/null || true - - # Small delay only if scan was very fast (let user see the spinner briefly) - if [[ "$show_progress" == "true" ]] && [[ ${elapsed:-0} -lt 1 ]]; then - sleep 0.2 - fi - - # Combine and sort - only keep top items - # Ensure we handle empty files gracefully - true > "$output_file" - if [[ -f "$temp_dirs" ]] || [[ -f "$temp_files" ]]; then - cat "$temp_dirs" "$temp_files" 2> /dev/null | sort -t'|' -k1 -rn | head -"$max_items" > "$output_file" || true - fi - - # Cleanup - rm -f "$temp_dirs" "$temp_files" 2> /dev/null -} - -# Calculate directory sizes and update (now only used for deep refresh) -calculate_dir_sizes() { - local items_file="$1" - local max_items="${2:-15}" # Only recalculate first 15 by default - local temp_file="${items_file}.calc" - - # Since we now scan with actual sizes, this function is mainly for refresh - # Just re-sort the existing data - sort -t'|' -k1 -rn "$items_file" > "$temp_file" - - # Only update if source file still exists (might have been deleted if user quit) - if [[ -f "$items_file" ]]; then - mv "$temp_file" "$items_file" 2> /dev/null || true - else - rm -f "$temp_file" 2> /dev/null || true - fi -} - -# Combine initial scan results (large files + directories) into one list -combine_initial_scan_results() { - local output_file="$1" - local temp_large="$TEMP_PREFIX.large" - local temp_dirs="$TEMP_PREFIX.dirs" - - true > "$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 - parent=$(dirname "$path") - if [[ "$parent" == "$CURRENT_PATH" ]]; then - echo "$size|file|$path" - fi - done < "$temp_large" >> "$output_file" - fi - - # Sort by size - sort -t'|' -k1 -rn "$output_file" -o "$output_file" -} - -# Show all volumes overview and let user select -show_volumes_overview() { - local temp_volumes="$TEMP_PREFIX.volumes" - - # Collect most useful locations (quick display, no size calculation) - { - # Priority order for display (prioritized by typical usefulness) - [[ -d "$HOME" ]] && echo "1000|$HOME|Home Directory" - [[ -d "$HOME/Downloads" ]] && echo "900|$HOME/Downloads|Downloads" - [[ -d "/Applications" ]] && echo "800|/Applications|Applications" - [[ -d "$HOME/Library" ]] && echo "700|$HOME/Library|User Library" - [[ -d "/Library" ]] && echo "600|/Library|System Library" - - # External volumes (filter obvious system mounts) - if [[ -d "/Volumes" ]]; then - local vol_priority=500 - find /Volumes -mindepth 1 -maxdepth 1 -type d 2> /dev/null | while IFS= read -r vol; do - local vol_name - vol_name=$(basename "$vol") - - # Skip internal/system volumes and dmg helper mounts, but keep user disks - case "$vol_name" in - "MacintoshHD" | "Macintosh HD" | "Macintosh HD - Data") continue ;; - dmg.* | *.dmg) continue ;; - esac - - echo "$((vol_priority))|$vol|Volume: $vol_name" - ((vol_priority--)) - done - fi - } | sort -t'|' -k1 -rn > "$temp_volumes" - - # Setup alternate screen and hide cursor (keep hidden throughout) - tput smcup 2> /dev/null || true - printf "\033[?25l" >&2 # Hide cursor - - cleanup_volumes() { - printf "\033[?25h" >&2 # Show cursor - tput rmcup 2> /dev/null || true - } - trap cleanup_volumes EXIT INT TERM - - # Force cursor hidden at the start - stty -echo 2> /dev/null || true - - local cursor=0 - local total_items - total_items=$(wc -l < "$temp_volumes" | tr -d ' ') - - while true; do - # Ensure cursor is always hidden - printf "\033[?25l" >&2 - - # Drain burst input (trackpad scroll -> many arrows) - type drain_pending_input > /dev/null 2>&1 && drain_pending_input - # Build output buffer to reduce flicker - local output="" - output+="\033[?25l" # Hide cursor - output+="\033[H\033[J" - output+=$'\n' - output+="\033[0;35mSelect a location to explore\033[0m"$'\n' - output+=$'\n' - - local idx=0 - while IFS='|' read -r _ path display_name; do - # Build line (simple display without size) - local line="" - if [[ $idx -eq $cursor ]]; then - line=$(printf " ${GREEN}${ICON_ARROW}${NC} ${BLUE}%s${NC}" "$display_name") - else - line=$(printf " ${GRAY}%s${NC}" "$display_name") - fi - output+="$line"$'\n' - - ((idx++)) - done < "$temp_volumes" - - output+=$'\n' - - # Output everything at once - printf "%b" "$output" >&2 - - # Read key (suppress any escape sequences that might leak) - local key - key=$(read_key 2> /dev/null || echo "OTHER") - - case "$key" in - "UP") - ((cursor > 0)) && ((cursor--)) - ;; - "DOWN") - ((cursor < total_items - 1)) && ((cursor++)) - ;; - "ENTER" | "RIGHT") - # Get selected path and enter it - local selected_path="" - idx=0 - while IFS='|' read -r _ path _; do - if [[ $idx -eq $cursor ]]; then - selected_path="$path" - break - fi - ((idx++)) - done < "$temp_volumes" - - if [[ -n "$selected_path" ]] && [[ -d "$selected_path" ]]; then - # Save cursor for potential return - local saved_cursor=$cursor - - # Don't cleanup yet - stay in alternate screen - trap - EXIT INT TERM - - # Enter drill-down, check return value - if interactive_drill_down "$selected_path" ""; then - # User quit (Q/ESC) - cleanup and exit - cleanup_volumes - return 0 - else - # User went back (LEFT at root) - return to menu - # Restore trap - trap cleanup_volumes EXIT INT TERM - cursor=$saved_cursor - # Just continue loop to redraw menu - fi - fi - ;; - "LEFT") - # In volumes view, LEFT does nothing (already at top level) - # User must press q/ESC to quit - ;; - "QUIT" | "q") - # Quit the volumes view - break - ;; - esac - done - - cleanup_volumes - trap - EXIT INT TERM -} - -# Interactive drill-down mode -interactive_drill_down() { - local start_path="$1" - local current_path="$start_path" - local path_stack=() - local cursor=0 - local scroll_offset=0 # New: for scrolling - local need_scan=true - local wait_for_calc=false # Don't wait on first load, let user press 'r' - local temp_items="$TEMP_PREFIX.items" - local status_message="" - - # Cache variables to avoid recalculation - local -a items=() - local total_items=0 - - # Directory cache: store scan results for each visited directory - # Use temp files because bash 3.2 doesn't have associative arrays - local cache_dir="$TEMP_PREFIX.cache.$$" - mkdir -p "$cache_dir" 2> /dev/null || true - - # Note: We're already in alternate screen from show_volumes_overview - # Just hide cursor, don't re-enter alternate screen - printf "\033[?25l" # Hide cursor - - # Save terminal settings and disable echo - local old_tty_settings="" - if [[ -t 0 ]]; then - old_tty_settings=$(stty -g 2> /dev/null || echo "") - stty -echo 2> /dev/null || true - fi - - # Cleanup on exit (but don't exit alternate screen - may return to menu) - cleanup_drill_down() { - # Restore terminal settings - if [[ -n "${old_tty_settings:-}" ]]; then - stty "$old_tty_settings" 2> /dev/null || true - fi - printf "\033[?25h" # Show cursor - # Don't call tput rmcup - we may be returning to volumes menu - [[ -d "${cache_dir:-}" ]] && rm -rf "$cache_dir" 2> /dev/null || true # Clean up cache - } - trap cleanup_drill_down EXIT INT TERM - - # Drain any input that accumulated before entering interactive mode - type drain_pending_input > /dev/null 2>&1 && drain_pending_input - - while true; do - # Ensure cursor is always hidden during navigation - printf "\033[?25l" >&2 - - # Only scan if needed (directory changed or refresh requested) - if [[ "$need_scan" == "true" ]]; then - # Generate cache key (use md5 hash of path) - local cache_key - cache_key=$(echo "$current_path" | md5 2> /dev/null || echo "$current_path" | shasum | cut -d' ' -f1) - local cache_file="$cache_dir/$cache_key" - - # Check if we have cached results for this directory - if [[ -f "$cache_file" ]] && [[ "$wait_for_calc" != "true" ]]; then - # Load from cache (instant!) - cp "$cache_file" "$temp_items" - else - # Fast scan: load more items for scrolling (top 50) - # Note: scan function will handle screen clearing and progress display - # Use || true to prevent exit on scan failure - scan_directory_contents_fast "$current_path" "$temp_items" 50 true || { - # Scan failed - create empty result file - true > "$temp_items" - } - - # Save to cache for next time (only if not empty) - if [[ -s "$temp_items" ]]; then - cp "$temp_items" "$cache_file" 2> /dev/null || true - fi - fi - - # Load items into array - items=() - if [[ -f "$temp_items" ]] && [[ -s "$temp_items" ]]; then - while IFS='|' read -r size type path; do - items+=("$size|$type|$path") - done < "$temp_items" - fi - total_items=${#items[@]} - - # No more calculating state - need_scan=false - wait_for_calc=false - - # Reset scroll when entering new directory - scroll_offset=0 - - # Drain any input accumulated during scanning - type drain_pending_input > /dev/null 2>&1 && drain_pending_input - - # Check if empty or scan failed - if [[ $total_items -eq 0 ]]; then - # Check if directory actually exists and is readable - if [[ ! -d "$current_path" ]] || [[ ! -r "$current_path" ]]; then - # Directory doesn't exist or can't read - show error - printf "\033[H\033[J" >&2 - echo "" >&2 - echo " ${RED}Error: Cannot access directory${NC}" >&2 - echo " ${GRAY}Path: $current_path${NC}" >&2 - echo "" >&2 - echo " ${GRAY}Press any key to go back...${NC}" >&2 - read_key > /dev/null 2>&1 - else - # Directory exists but scan returned nothing (timeout or empty) - printf "\033[H\033[J" >&2 - echo "" >&2 - echo " ${YELLOW}Empty directory or scan timeout${NC}" >&2 - echo " ${GRAY}Path: $current_path${NC}" >&2 - echo "" >&2 - echo " ${GRAY}Press ${NC}${GREEN}R${NC}${GRAY} to retry, any other key to go back${NC}" >&2 - - local retry_key - retry_key=$(read_key 2> /dev/null || echo "OTHER") - - if [[ "$retry_key" == "RETRY" ]]; then - # Retry scan - need_scan=true - continue - fi - fi - - # Go back to parent - if [[ ${#path_stack[@]} -gt 0 ]]; then - # Use bash 3.2 compatible way to get last element - local stack_size=${#path_stack[@]} - local last_index - last_index=$((stack_size - 1)) - current_path="${path_stack[$last_index]}" - unset "path_stack[$last_index]" - cursor=0 - need_scan=true - continue - else - # Can't go back further, just stay and show empty view - # Add a dummy item so the interface doesn't break - items=("0|dir|$current_path") - total_items=1 - fi - fi - fi - - # Build output buffer once for smooth rendering - local output="" - output+="\033[?25l" # Hide cursor - output+="\033[H\033[J" # Clear screen - output+=$'\n' - output+="\033[0;35mDisk space explorer > $(echo "$current_path" | sed "s|^$HOME|~|")\033[0m"$'\n' - output+=$'\n' - - local max_show=15 # Show 15 items per page - local page_start=$scroll_offset - local page_end - page_end=$((scroll_offset + max_show)) - [[ $page_end -gt $total_items ]] && page_end=$total_items - - local display_idx=0 - local idx=0 - for item_info in "${items[@]}"; do - # Skip items before current page - if [[ $idx -lt $page_start ]]; then - ((idx++)) - continue - fi - - # Stop if we've shown enough items for this page - if [[ $idx -ge $page_end ]]; then - break - fi - - local size="${item_info%%|*}" - local rest="${item_info#*|}" - local type="${rest%%|*}" - local path="${rest#*|}" - local name - name=$(basename "$path") - - local human_size - if [[ "$size" -eq 0 ]]; then - human_size="0B" - else - human_size=$(bytes_to_human "$size") - fi - - # Determine label and color hints - local badge="$BADGE_FILE" color="${NC}" - if [[ "$type" == "dir" ]]; then - badge="$BADGE_DIR" color="${BLUE}" - if [[ $size -gt 10737418240 ]]; then - color="${RED}" - elif [[ $size -gt 1073741824 ]]; then - color="${YELLOW}" - fi - else - local ext="${name##*.}" - local info - info=$(get_file_info "$path") - badge="${info%|*}" - case "$ext" in - dmg | iso | pkg | zip | tar | gz | rar | 7z) - color="${YELLOW}" - ;; - mov | mp4 | avi | mkv | webm | jpg | jpeg | png | gif | heic) - color="${YELLOW}" - ;; - log) - color="${GRAY}" - ;; - esac - fi - - # Truncate name - if [[ ${#name} -gt 50 ]]; then name="${name:0:47}..."; fi - - # Build line with emoji badge, size, and name - local line - if [[ $idx -eq $cursor ]]; then - line=$(printf " ${GREEN}${ICON_ARROW}${NC} %s%s${NC} %10s %s${NC}" "$color" "$badge" "$human_size" "$name") - else - line=$(printf " %s%s${NC} %10s %s${NC}" "$color" "$badge" "$human_size" "$name") - fi - output+="$line"$'\n' - - ((idx++)) - ((display_idx++)) - done - - output+=$'\n' - - # Show pagination info if there are more items - if [[ $total_items -gt $max_show ]]; then - local showing_end=$page_end - output+=" ${GRAY}Showing $((page_start + 1))-$showing_end of $total_items items${NC}"$'\n' - output+=$'\n' - fi - - if [[ -n "$status_message" ]]; then - output+=" $status_message"$'\n\n' - status_message="" - fi - - # Bottom help bar - output+=" ${GRAY}${ICON_NAV_UP}/${ICON_NAV_DOWN}${NC} Navigate ${GRAY}|${NC} ${GRAY}Enter${NC} Open ${GRAY}|${NC} ${GRAY}${ICON_NAV_LEFT}${NC} Back ${GRAY}|${NC} ${GRAY}Del${NC} Delete ${GRAY}|${NC} ${GRAY}O${NC} Finder ${GRAY}|${NC} ${GRAY}Q/ESC${NC} Quit"$'\n' - - # Output everything at once (single write = no flicker) - printf "%b" "$output" >&2 - - # Read key directly without draining (to preserve all user input) - local key - key=$(read_key 2> /dev/null || echo "OTHER") - - # Debug: uncomment to see what keys are being received - # printf "\rDEBUG: Received key=[%s] " "$key" >&2 - # sleep 1 - - case "$key" in - "UP") - # Move cursor up - if [[ $cursor -gt 0 ]]; then - ((cursor--)) - # Scroll up if cursor goes above visible area - if [[ $cursor -lt $scroll_offset ]]; then - scroll_offset=$cursor - fi - fi - ;; - "DOWN") - # Move cursor down - if [[ $cursor -lt $((total_items - 1)) ]]; then - ((cursor++)) - # Scroll down if cursor goes below visible area - local page_end - page_end=$((scroll_offset + max_show)) - if [[ $cursor -ge $page_end ]]; then - scroll_offset=$((cursor - max_show + 1)) - fi - fi - ;; - "ENTER" | "RIGHT") - # Enter selected item - directory or file - if [[ $cursor -lt ${#items[@]} ]]; then - local selected="${items[$cursor]}" - local size="${selected%%|*}" - local rest="${selected#*|}" - local type="${rest%%|*}" - local selected_path="${rest#*|}" - - if [[ "$type" == "dir" ]]; then - # Push current path to stack and enter the directory - path_stack+=("$current_path") - current_path="$selected_path" - cursor=0 - need_scan=true - else - # It's a file - open it for viewing - local file_ext="${selected_path##*.}" - local filename - filename=$(basename "$selected_path") - local open_success=false - - # For text-like files, use less or fallback to open - case "$file_ext" in - txt | log | md | json | xml | yaml | yml | conf | cfg | ini | sh | bash | zsh | py | js | ts | go | rs | c | cpp | h | java | rb | php | html | css | sql) - # Clear screen and show loading message - printf "\033[H\033[J" - echo "" - echo " ${BLUE}Opening file:${NC} $filename" - echo "" - - # Try less first (best for text viewing) - if command -v less &> /dev/null; then - # Exit alternate screen only for less - printf "\033[?25h" # Show cursor - tput rmcup 2> /dev/null || true - - less -F "$selected_path" 2> /dev/null && open_success=true - - # Return to alternate screen - tput smcup 2> /dev/null || true - printf "\033[?25l" # Hide cursor - else - # Fallback to system open if less is not available - echo " ${GRAY}Launching default application...${NC}" - if command -v open &> /dev/null; then - open "$selected_path" 2> /dev/null && open_success=true - if [[ "$open_success" == "true" ]]; then - echo "" - echo " ${GREEN}${ICON_SUCCESS}${NC} File opened in external app" - sleep 0.8 - fi - fi - fi - ;; - *) - # For other files, use system open (keep in alternate screen) - # Show message without flashing - printf "\033[H\033[J" - echo "" - echo " ${BLUE}Opening file:${NC} $filename" - echo "" - echo " ${GRAY}Launching default application...${NC}" - - if command -v open &> /dev/null; then - open "$selected_path" 2> /dev/null && open_success=true - - # Show brief success message - if [[ "$open_success" == "true" ]]; then - echo "" - echo " ${GREEN}${ICON_SUCCESS}${NC} File opened in external app" - sleep 0.8 - fi - fi - ;; - esac - - # If nothing worked, show error message - if [[ "$open_success" != "true" ]]; then - printf "\033[H\033[J" - echo "" - echo " ${YELLOW}Warning:${NC} Could not open file" - echo "" - echo " ${GRAY}File: $selected_path${NC}" - echo " ${GRAY}Press any key to return...${NC}" - read -n 1 -s 2> /dev/null - fi - fi - fi - ;; - "LEFT") - # Go back to parent directory with left arrow - if [[ ${#path_stack[@]} -gt 0 ]]; then - # Pop from stack and go back - # Use bash 3.2 compatible way to get last element - local stack_size=${#path_stack[@]} - local last_index - last_index=$((stack_size - 1)) - current_path="${path_stack[$last_index]}" - unset "path_stack[$last_index]" - cursor=0 - scroll_offset=0 - need_scan=true - else - # Already at start path - return to volumes menu - # Don't show cursor or exit screen - menu will handle it - if [[ -n "${old_tty_settings:-}" ]]; then - stty "$old_tty_settings" 2> /dev/null || true - fi - [[ -d "${cache_dir:-}" ]] && rm -rf "$cache_dir" 2> /dev/null || true - trap - EXIT INT TERM - return 1 # Return to menu - fi - ;; - "OPEN") - if command -v open > /dev/null 2>&1; then - if open "$current_path" > /dev/null 2>&1; then - status_message="${GREEN}${ICON_SUCCESS}${NC} Finder opened: ${GRAY}$current_path${NC}" - else - status_message="${YELLOW}Warning:${NC} Could not open ${GRAY}$current_path${NC}" - fi - else - status_message="${YELLOW}Warning:${NC} 'open' command not available" - fi - ;; - "DELETE") - # Delete selected item (file or directory) - if [[ $cursor -lt ${#items[@]} ]]; then - local selected="${items[$cursor]}" - local size="${selected%%|*}" - local rest="${selected#*|}" - local type="${rest%%|*}" - local selected_path="${rest#*|}" - local selected_name - selected_name=$(basename "$selected_path") - local human_size - human_size=$(bytes_to_human "$size") - - # Check if sudo is needed - local needs_sudo=false - if [[ ! -w "$selected_path" ]] || [[ ! -w "$(dirname "$selected_path")" ]]; then - needs_sudo=true - fi - - # Build simple confirmation - printf "\033[H\033[J" - echo "" - echo "" - - if [[ "$type" == "dir" ]]; then - echo " ${RED}Delete folder? ${YELLOW}Warning:${NC} This action cannot be undone!" - else - echo " ${RED}Delete file? ${YELLOW}Warning:${NC} This action cannot be undone!" - fi - - echo "" - - # Show icon based on type - if [[ "$type" == "dir" ]]; then - echo " ${BADGE_DIR} ${YELLOW}$selected_name${NC}" - else - local info - info=$(get_file_info "$selected_path") - local badge="${info%|*}" - echo " $badge ${YELLOW}$selected_name${NC}" - fi - - echo " ${GRAY}Size: $human_size${NC}" - echo " ${GRAY}Path: $selected_path${NC}" - - if [[ "$needs_sudo" == "true" ]]; then - echo "" - echo " ${YELLOW}Warning:${NC} Requires admin privileges" - fi - - echo "" - echo -e " ${PURPLE}${ICON_ARROW}${NC} Press ${GREEN}Enter${NC} to delete, ${GRAY}ESC${NC} to cancel" - echo "" - - # Read confirmation - local confirm - confirm=$(read_key 2> /dev/null || echo "QUIT") - - if [[ "$confirm" == "ENTER" ]]; then - # Request sudo if needed before deletion - if [[ "$needs_sudo" == "true" ]]; then - printf "\033[H\033[J" - echo "" - echo "" - if ! request_sudo_access "Admin access required to delete this item"; then - echo "" - echo " ${RED}${ICON_ERROR} Admin access denied${NC}" - sleep 1.5 - continue - fi - fi - - # Show deleting message - printf "\033[H\033[J" - echo "" - echo " ${BLUE}Deleting...${NC}" - echo "" - - # Try to delete with sudo if needed - local delete_success=false - if [[ "$needs_sudo" == "true" ]]; then - if sudo rm -rf "$selected_path" 2> /dev/null; then - delete_success=true - fi - else - if rm -rf "$selected_path" 2> /dev/null; then - delete_success=true - fi - fi - - if [[ "$delete_success" == "true" ]]; then - echo " ${GREEN}${ICON_SUCCESS} Deleted successfully (freed $human_size)${NC}" - sleep 0.8 - - # Clear cache to force rescan - local cache_key - cache_key=$(echo "$current_path" | md5 2> /dev/null || echo "$current_path" | shasum | cut -d' ' -f1) - local cache_file="$cache_dir/$cache_key" - rm -f "$cache_file" 2> /dev/null || true - - # Refresh the view - need_scan=true - - # Adjust cursor if needed - if [[ $cursor -ge $((total_items - 1)) ]] && [[ $cursor -gt 0 ]]; then - ((cursor--)) - fi - else - echo " ${RED}${ICON_ERROR} Failed to delete${NC}" - echo "" - echo " ${YELLOW}Possible reasons:${NC}" - echo " ${ICON_LIST} File is being used by another application" - echo " ${ICON_LIST} Insufficient permissions" - echo " ${ICON_LIST} System protection (SIP) prevents deletion" - echo "" - echo " ${GRAY}Press any key to continue...${NC}" - read_key > /dev/null 2>&1 - fi - fi - fi - ;; - "QUIT" | "q") - # Quit the explorer - cleanup_drill_down - trap - EXIT INT TERM - return 0 # Return true to indicate normal exit - ;; - *) - # Unknown key - ignore it - ;; - esac - done - - # Cleanup is handled by trap - return 0 # Normal exit if loop ends -} - -# Main interactive loop -interactive_mode() { - CURSOR_POS=0 - VIEW_MODE="overview" - - while true; do - type drain_pending_input > /dev/null 2>&1 && drain_pending_input - display_interactive_menu - - local key - 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 - max_count=$(count_directories) - ((CURSOR_POS < max_count - 1)) && ((CURSOR_POS++)) - fi - ;; - "RIGHT") - if [[ "$VIEW_MODE" == "navigate" ]]; then - # Enter selected directory - local selected_path - 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 - 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 - 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 - 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 - 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" - - CURRENT_PATH="$target_path" - - # Create cache directory - mkdir -p "$CACHE_DIR" 2> /dev/null || true - - # Start with volumes overview to let user choose location - show_volumes_overview -} - -# Run if executed directly -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - main "$@" +GO_BIN="$SCRIPT_DIR/analyze-go" +if [[ -x "$GO_BIN" ]]; then + exec "$GO_BIN" "$@" fi + +echo "Bundled analyzer binary not found. Please reinstall Mole or run 'mo update' to restore it." >&2 +exit 1 diff --git a/bin/clean.sh b/bin/clean.sh index 7bf1290..3f6991a 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -370,6 +370,42 @@ safe_clean() { return 0 } +clean_ds_store_tree() { + local target="$1" + local label="$2" + + [[ -d "$target" ]] || return 0 + + local file_count=0 + local total_bytes=0 + + while IFS= read -r -d '' ds_file; do + local size + size=$(stat -f%z "$ds_file" 2> /dev/null || echo 0) + total_bytes=$((total_bytes + size)) + ((file_count++)) + if [[ "$DRY_RUN" != "true" ]]; then + rm -f "$ds_file" 2> /dev/null || true + fi + done < <(find "$target" -type f -name '.DS_Store' -print0 2> /dev/null) + + if [[ $file_count -gt 0 ]]; then + local size_human + size_human=$(bytes_to_human "$total_bytes") + if [[ "$DRY_RUN" == "true" ]]; then + echo -e " ${YELLOW}→${NC} $label ${YELLOW}($file_count files, $size_human dry)${NC}" + else + echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label ${GREEN}($file_count files, $size_human)${NC}" + fi + + local size_kb=$(( (total_bytes + 1023) / 1024 )) + ((files_cleaned += file_count)) + ((total_size_cleaned += size_kb)) + ((total_items++)) + note_activity + fi +} + start_cleanup() { clear printf '\n' @@ -598,6 +634,24 @@ perform_cleanup() { safe_clean ~/Downloads/*.part "Incomplete downloads (partial)" end_section + start_section "Finder metadata cleanup" + clean_ds_store_tree "$HOME" "Home directory (.DS_Store)" + + if [[ -d "/Volumes" ]]; then + for volume in /Volumes/*; do + [[ -d "$volume" && -w "$volume" ]] || continue + + local fs_type="" + fs_type=$(df -T "$volume" 2> /dev/null | tail -1 | awk '{print $2}') + case "$fs_type" in + nfs | smbfs | afpfs | cifs | webdav) continue ;; + esac + + clean_ds_store_tree "$volume" "$(basename "$volume") volume (.DS_Store)" + done + fi + end_section + # ===== 3. macOS System Caches ===== start_section "macOS system caches" safe_clean ~/Library/Saved\ Application\ State/* "Saved application states" diff --git a/cmd/analyze/main.go b/cmd/analyze/main.go new file mode 100644 index 0000000..81df3d1 --- /dev/null +++ b/cmd/analyze/main.go @@ -0,0 +1,1272 @@ +package main + +import ( + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + "sync/atomic" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +const ( + maxEntries = 20 + maxLargeFiles = 20 + barWidth = 24 + minLargeFileSize = 100 << 20 // 100 MB + entryViewport = 10 + largeViewport = 10 +) + +// Directories to fold: calculate size but don't expand children +var foldDirs = map[string]bool{ + ".git": true, + "node_modules": true, + ".Trash": true, + ".npm": true, + ".cache": true, + ".yarn": true, + ".pnpm-store": true, + "__pycache__": true, + ".pytest_cache": true, + "target": true, // Rust/Java build output + "build": true, + "dist": true, + ".next": true, + ".nuxt": true, +} + +// System directories to skip (macOS specific) +var skipSystemDirs = map[string]bool{ + "dev": true, + "tmp": true, + "private": true, + "cores": true, + "net": true, + "home": true, + "System": true, // macOS system files + "sbin": true, + "bin": true, + "etc": true, + "var": true, + ".vol": true, + ".Spotlight-V100": true, + ".fseventsd": true, + ".DocumentRevisions-V100": true, + ".TemporaryItems": true, +} + +// File extensions to skip for large file tracking +var skipExtensions = map[string]bool{ + ".go": true, + ".js": true, + ".ts": true, + ".jsx": true, + ".tsx": true, + ".py": true, + ".rb": true, + ".java": true, + ".c": true, + ".cpp": true, + ".h": true, + ".hpp": true, + ".rs": true, + ".swift": true, + ".m": true, + ".mm": true, + ".sh": true, + ".txt": true, + ".md": true, + ".json": true, + ".xml": true, + ".yaml": true, + ".yml": true, + ".toml": true, + ".css": true, + ".scss": true, + ".html": true, + ".svg": true, +} + +var spinnerFrames = []string{"ā ‹", "ā ™", "ā ¹", "ā ø", "ā ¼", "ā “", "ā ¦", "ā §"} + +const ( + colorPurple = "\033[0;35m" + colorBlue = "\033[0;34m" + colorGray = "\033[0;90m" + colorRed = "\033[0;31m" + colorYellow = "\033[1;33m" + colorGreen = "\033[0;32m" + colorCyan = "\033[0;36m" + colorReset = "\033[0m" + colorBold = "\033[1m" + colorBgCyan = "\033[46m" + colorBgDark = "\033[100m" + colorInvert = "\033[7m" +) + +type dirEntry struct { + name string + path string + size int64 + isDir bool +} + +type fileEntry struct { + name string + path string + size int64 +} + +type scanResult struct { + entries []dirEntry + largeFiles []fileEntry + totalSize int64 +} + +type historyEntry struct { + path string + entries []dirEntry + largeFiles []fileEntry + totalSize int64 + selected int + entryOffset int + largeSelected int + largeOffset int + dirty bool +} + +type scanResultMsg struct { + result scanResult + err error +} + +type tickMsg time.Time + +type model struct { + path string + history []historyEntry + entries []dirEntry + largeFiles []fileEntry + selected int + offset int + status string + totalSize int64 + scanning bool + spinner int + filesScanned *int64 + dirsScanned *int64 + bytesScanned *int64 + currentPath *string + showLargeFiles bool + isOverview bool + deleteConfirm bool + deleteTarget *dirEntry + cache map[string]historyEntry + largeSelected int + largeOffset int +} + +func main() { + target := os.Getenv("MO_ANALYZE_PATH") + if target == "" && len(os.Args) > 1 { + target = os.Args[1] + } + + var abs string + var isOverview bool + + if target == "" { + // Default to overview mode + isOverview = true + abs = "/" + } else { + var err error + abs, err = filepath.Abs(target) + if err != nil { + fmt.Fprintf(os.Stderr, "cannot resolve %q: %v\n", target, err) + os.Exit(1) + } + isOverview = false + } + + p := tea.NewProgram(newModel(abs, isOverview)) + if err := p.Start(); err != nil { + fmt.Fprintf(os.Stderr, "analyzer error: %v\n", err) + os.Exit(1) + } +} + +func newModel(path string, isOverview bool) model { + var filesScanned, dirsScanned, bytesScanned int64 + currentPath := "" + + m := model{ + path: path, + selected: 0, + status: "Preparing scan...", + scanning: !isOverview, + filesScanned: &filesScanned, + dirsScanned: &dirsScanned, + bytesScanned: &bytesScanned, + currentPath: ¤tPath, + showLargeFiles: false, + isOverview: isOverview, + cache: make(map[string]historyEntry), + } + + // In overview mode, create shortcut entries + if isOverview { + m.scanning = false + m.entries = createOverviewEntries() + m.status = "Ready" + } + + return m +} + +func createOverviewEntries() []dirEntry { + home := os.Getenv("HOME") + entries := []dirEntry{ + {name: "Home (~)", path: home, isDir: true}, + {name: "Library (~/Library)", path: filepath.Join(home, "Library"), isDir: true}, + {name: "Applications", path: "/Applications", isDir: true}, + {name: "System Library", path: "/Library", isDir: true}, + } + + // Add Volumes if exists + if _, err := os.Stat("/Volumes"); err == nil { + entries = append(entries, dirEntry{name: "Volumes", path: "/Volumes", isDir: true}) + } + + return entries +} + +func (m model) Init() tea.Cmd { + if m.isOverview { + return nil + } + return tea.Batch(m.scanCmd(m.path), tickCmd()) +} + +func (m model) scanCmd(path string) tea.Cmd { + return func() tea.Msg { + result, err := scanPathConcurrent(path, m.filesScanned, m.dirsScanned, m.bytesScanned, m.currentPath) + return scanResultMsg{result: result, err: err} + } +} + +func tickCmd() tea.Cmd { + return tea.Tick(time.Millisecond*120, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m.updateKey(msg) + case scanResultMsg: + m.scanning = false + if msg.err != nil { + m.status = fmt.Sprintf("Scan failed: %v", msg.err) + return m, nil + } + m.entries = msg.result.entries + m.largeFiles = msg.result.largeFiles + m.totalSize = msg.result.totalSize + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) + m.clampEntrySelection() + m.clampLargeSelection() + m.cache[m.path] = cacheSnapshot(m) + return m, nil + case tickMsg: + if m.scanning { + m.spinner = (m.spinner + 1) % len(spinnerFrames) + return m, tickCmd() + } + return m, nil + default: + return m, nil + } +} + +func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Handle delete confirmation + if m.deleteConfirm { + if msg.String() == "delete" || msg.String() == "backspace" { + // Confirm delete + if m.deleteTarget != nil { + err := os.RemoveAll(m.deleteTarget.path) + if err != nil { + m.status = fmt.Sprintf("Failed to delete: %v", err) + } else { + m.status = fmt.Sprintf("Deleted %s", m.deleteTarget.name) + for i := range m.history { + m.history[i].dirty = true + } + for path := range m.cache { + entry := m.cache[path] + entry.dirty = true + m.cache[path] = entry + } + // Refresh the view + m.scanning = true + m.deleteConfirm = false + m.deleteTarget = nil + return m, tea.Batch(m.scanCmd(m.path), tickCmd()) + } + } + m.deleteConfirm = false + m.deleteTarget = nil + return m, nil + } else if msg.String() == "esc" || msg.String() == "q" { + // Cancel delete with ESC or Q + m.status = "Cancelled" + m.deleteConfirm = false + m.deleteTarget = nil + return m, nil + } else { + // Any other key also cancels + m.status = "Cancelled" + m.deleteConfirm = false + m.deleteTarget = nil + return m, nil + } + } + + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "esc": + if m.showLargeFiles { + m.showLargeFiles = false + return m, nil + } + return m, tea.Quit + case "up", "k": + if m.showLargeFiles { + if m.largeSelected > 0 { + m.largeSelected-- + if m.largeSelected < m.largeOffset { + m.largeOffset = m.largeSelected + } + } + } else if len(m.entries) > 0 && m.selected > 0 { + m.selected-- + if m.selected < m.offset { + m.offset = m.selected + } + } + case "down", "j": + if m.showLargeFiles { + if m.largeSelected < len(m.largeFiles)-1 { + m.largeSelected++ + if m.largeSelected >= m.largeOffset+largeViewport { + m.largeOffset = m.largeSelected - largeViewport + 1 + } + } + } else if len(m.entries) > 0 && m.selected < len(m.entries)-1 { + m.selected++ + if m.selected >= m.offset+entryViewport { + m.offset = m.selected - entryViewport + 1 + } + } + case "enter": + if m.showLargeFiles { + return m, nil + } + return m.enterSelectedDir() + case "right": + if m.showLargeFiles { + return m, nil + } + return m.enterSelectedDir() + case "b", "left": + if m.showLargeFiles { + m.showLargeFiles = false + return m, nil + } + if len(m.history) == 0 { + // Return to overview if at top level + if !m.isOverview { + m.isOverview = true + m.path = "/" + m.entries = createOverviewEntries() + m.selected = 0 + m.offset = 0 + m.status = "Ready" + m.scanning = false + } + return m, nil + } + last := m.history[len(m.history)-1] + m.history = m.history[:len(m.history)-1] + m.path = last.path + m.selected = last.selected + m.offset = last.entryOffset + m.largeSelected = last.largeSelected + m.largeOffset = last.largeOffset + m.isOverview = false + if last.dirty { + m.status = "Scanning..." + m.scanning = true + return m, tea.Batch(m.scanCmd(m.path), tickCmd()) + } + m.entries = last.entries + m.largeFiles = last.largeFiles + m.totalSize = last.totalSize + m.clampEntrySelection() + m.clampLargeSelection() + if len(m.entries) == 0 { + m.selected = 0 + } else if m.selected >= len(m.entries) { + m.selected = len(m.entries) - 1 + } + if m.selected < 0 { + m.selected = 0 + } + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) + m.scanning = false + return m, nil + case "r": + m.status = "Refreshing..." + m.scanning = true + return m, tea.Batch(m.scanCmd(m.path), tickCmd()) + case "l": + m.showLargeFiles = !m.showLargeFiles + if m.showLargeFiles { + m.largeSelected = 0 + m.largeOffset = 0 + } + case "o": + // Open selected entry + if m.showLargeFiles { + if len(m.largeFiles) > 0 { + selected := m.largeFiles[m.largeSelected] + go func() { + _ = exec.Command("open", selected.path).Run() + }() + m.status = fmt.Sprintf("Opening %s...", selected.name) + } + } else if len(m.entries) > 0 && !m.isOverview { + selected := m.entries[m.selected] + go func() { + _ = exec.Command("open", selected.path).Run() + }() + m.status = fmt.Sprintf("Opening %s...", selected.name) + } + case "f", "F": + // Reveal selected entry in Finder + if m.showLargeFiles { + if len(m.largeFiles) > 0 { + selected := m.largeFiles[m.largeSelected] + go func(path string) { + _ = exec.Command("open", "-R", path).Run() + }(selected.path) + m.status = fmt.Sprintf("Revealing %s in Finder...", selected.name) + } + } else if len(m.entries) > 0 && !m.isOverview { + selected := m.entries[m.selected] + go func(path string) { + _ = exec.Command("open", "-R", path).Run() + }(selected.path) + m.status = fmt.Sprintf("Revealing %s in Finder...", selected.name) + } + case "delete", "backspace": + // Delete selected file or directory + if m.showLargeFiles { + if len(m.largeFiles) > 0 { + selected := m.largeFiles[m.largeSelected] + m.deleteConfirm = true + m.deleteTarget = &dirEntry{ + name: selected.name, + path: selected.path, + size: selected.size, + isDir: false, + } + } + } else if len(m.entries) > 0 && !m.isOverview { + selected := m.entries[m.selected] + m.deleteConfirm = true + m.deleteTarget = &selected + } + } + return m, nil +} + +func (m model) enterSelectedDir() (tea.Model, tea.Cmd) { + if len(m.entries) == 0 { + return m, nil + } + selected := m.entries[m.selected] + if selected.isDir { + if !m.isOverview { + m.history = append(m.history, snapshotFromModel(m)) + } + m.path = selected.path + m.selected = 0 + m.offset = 0 + m.status = "Scanning..." + m.scanning = true + m.isOverview = false + if cached, ok := m.cache[m.path]; ok && !cached.dirty { + m.entries = cloneDirEntries(cached.entries) + m.largeFiles = cloneFileEntries(cached.largeFiles) + m.totalSize = cached.totalSize + m.selected = cached.selected + m.offset = cached.entryOffset + m.largeSelected = cached.largeSelected + m.largeOffset = cached.largeOffset + m.clampEntrySelection() + m.clampLargeSelection() + m.status = fmt.Sprintf("Cached view for %s", displayPath(m.path)) + m.scanning = false + return m, nil + } + return m, tea.Batch(m.scanCmd(m.path), tickCmd()) + } + m.status = fmt.Sprintf("File: %s (%s)", selected.name, humanizeBytes(selected.size)) + return m, nil +} + +func (m model) View() string { + var b strings.Builder + fmt.Fprintln(&b) + + if m.deleteConfirm && m.deleteTarget != nil { + // Show delete confirmation prominently at the top + fmt.Fprintf(&b, "%sDelete: %s (%s)? Press Delete again to confirm, ESC to cancel%s\n\n", + colorRed, m.deleteTarget.name, humanizeBytes(m.deleteTarget.size), colorReset) + } + + if m.isOverview { + fmt.Fprintf(&b, "%sAnalyze Disk%s\n", colorPurple, colorReset) + fmt.Fprintf(&b, "%sSelect a location to explore:%s\n\n", colorGray, colorReset) + } else { + fmt.Fprintf(&b, "%sAnalyze Disk%s %s%s%s", colorPurple, colorReset, colorGray, displayPath(m.path), colorReset) + if !m.scanning { + fmt.Fprintf(&b, " | Total: %s", humanizeBytes(m.totalSize)) + } + fmt.Fprintln(&b) + } + + if m.scanning { + filesScanned := atomic.LoadInt64(m.filesScanned) + dirsScanned := atomic.LoadInt64(m.dirsScanned) + bytesScanned := atomic.LoadInt64(m.bytesScanned) + + fmt.Fprintf(&b, "\n%s%s%s%s Scanning: %s%s files%s, %s%s dirs%s, %s%s%s\n", + colorCyan, colorBold, + spinnerFrames[m.spinner], + colorReset, + colorYellow, formatNumber(filesScanned), colorReset, + colorYellow, formatNumber(dirsScanned), colorReset, + colorGreen, humanizeBytes(bytesScanned), colorReset) + + currentPath := *m.currentPath + if currentPath != "" { + shortPath := displayPath(currentPath) + if len(shortPath) > 60 { + shortPath = "..." + shortPath[len(shortPath)-57:] + } + fmt.Fprintf(&b, "%s%s%s\n", colorGray, shortPath, colorReset) + } + + return b.String() + } + + fmt.Fprintln(&b) + + if m.showLargeFiles { + if len(m.largeFiles) == 0 { + fmt.Fprintln(&b, " No large files found (>=100MB)") + } else { + start := m.largeOffset + if start < 0 { + start = 0 + } + end := start + largeViewport + if end > len(m.largeFiles) { + end = len(m.largeFiles) + } + maxLargeSize := int64(1) + for _, file := range m.largeFiles { + if file.size > maxLargeSize { + maxLargeSize = file.size + } + } + for idx := start; idx < end; idx++ { + file := m.largeFiles[idx] + shortPath := displayPath(file.path) + if len(shortPath) > 56 { + shortPath = shortPath[:53] + "..." + } + entryPrefix := " " + if idx == m.largeSelected { + entryPrefix = fmt.Sprintf(" %s%sā–¶%s ", colorCyan, colorBold, colorReset) + } + nameColumn := padName(shortPath, 56) + size := humanizeBytes(file.size) + bar := coloredProgressBar(file.size, maxLargeSize, 0) + fmt.Fprintf(&b, "%s%2d) %s | šŸ“„ %s %s%10s%s\n", + entryPrefix, idx+1, bar, nameColumn, colorGray, size, colorReset) + } + } + } else { + if len(m.entries) == 0 { + fmt.Fprintln(&b, " Empty directory") + } else { + if m.isOverview { + // In overview mode, show simple list without sizes + for idx, entry := range m.entries { + displayIndex := idx + 1 + if idx == m.selected { + // Highlight selected entry + fmt.Fprintf(&b, " %s%sā–¶ %d) šŸ“ %s%s\n", colorCyan, colorBold, displayIndex, entry.name, colorReset) + } else { + fmt.Fprintf(&b, " %d) šŸ“ %s\n", displayIndex, entry.name) + } + } + } else { + // Normal mode with sizes and progress bars + maxSize := int64(1) + for _, entry := range m.entries { + if entry.size > maxSize { + maxSize = entry.size + } + } + + start := m.offset + if start < 0 { + start = 0 + } + end := start + entryViewport + if end > len(m.entries) { + end = len(m.entries) + } + + for idx := start; idx < end; idx++ { + entry := m.entries[idx] + icon := "šŸ“„" + if entry.isDir { + icon = "šŸ“" + } + size := humanizeBytes(entry.size) + name := trimName(entry.name) + paddedName := padName(name, 28) + + // Calculate percentage + percent := float64(entry.size) / float64(m.totalSize) * 100 + percentStr := fmt.Sprintf("%5.1f%%", percent) + + // Get colored progress bar + bar := coloredProgressBar(entry.size, maxSize, percent) + + // Color the size based on magnitude + var sizeColor string + if percent >= 50 { + sizeColor = colorRed + } else if percent >= 20 { + sizeColor = colorYellow + } else if percent >= 5 { + sizeColor = colorCyan + } else { + sizeColor = colorGray + } + + // Keep chart columns aligned even when arrow is shown + entryPrefix := " " + nameSegment := fmt.Sprintf("%s %s", icon, paddedName) + if idx == m.selected { + entryPrefix = fmt.Sprintf(" %s%sā–¶%s ", colorCyan, colorBold, colorReset) + nameSegment = fmt.Sprintf("%s%s %s%s", colorBold, icon, paddedName, colorReset) + } + + displayIndex := idx + 1 + fmt.Fprintf(&b, "%s%2d) %s %s | %s %s%10s%s\n", + entryPrefix, displayIndex, bar, percentStr, + nameSegment, sizeColor, size, colorReset) + } + } + } + } + + fmt.Fprintln(&b) + if m.isOverview { + fmt.Fprintf(&b, "%s ↑↓←→ Navigate | Q Quit%s\n", colorGray, colorReset) + } else if m.showLargeFiles { + fmt.Fprintf(&b, "%s ↑↓ Navigate | O Open | F Reveal | ⌫ Delete | Q Quit%s\n", colorGray, colorReset) + } else { + largeFileCount := len(m.largeFiles) + if largeFileCount > 0 { + fmt.Fprintf(&b, "%s ↑↓←→ Navigate | O Open | F Reveal | ⌫ Delete | L Large(%d) | Q Quit%s\n", colorGray, largeFileCount, colorReset) + } else { + fmt.Fprintf(&b, "%s ↑↓←→ Navigate | O Open | F Reveal | ⌫ Delete | Q Quit%s\n", colorGray, colorReset) + } + } + return b.String() +} + +func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *string) (scanResult, error) { + children, err := os.ReadDir(root) + if err != nil { + return scanResult{}, err + } + + tracker := newLargeFileTracker() + var total int64 + entries := make([]dirEntry, 0, len(children)) + var entriesMu sync.Mutex + + // Use worker pool for concurrent directory scanning + maxWorkers := runtime.NumCPU() * 2 + if maxWorkers < 4 { + maxWorkers = 4 + } + if maxWorkers > len(children) { + maxWorkers = len(children) + } + if maxWorkers < 1 { + maxWorkers = 1 + } + sem := make(chan struct{}, maxWorkers) + var wg sync.WaitGroup + + isRootDir := root == "/" + + for _, child := range children { + fullPath := filepath.Join(root, child.Name()) + + if child.IsDir() { + // In root directory, skip system directories completely + if isRootDir && skipSystemDirs[child.Name()] { + continue + } + + // For folded directories, calculate size quickly without expanding + if shouldFoldDir(child.Name()) { + wg.Add(1) + go func(name, path string) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + size := calculateDirSizeFast(path, filesScanned, dirsScanned, bytesScanned) + atomic.AddInt64(&total, size) + atomic.AddInt64(dirsScanned, 1) + + entriesMu.Lock() + entries = append(entries, dirEntry{ + name: name, + path: path, + size: size, + isDir: true, + }) + entriesMu.Unlock() + }(child.Name(), fullPath) + continue + } + + // Normal directory: full scan with detail + wg.Add(1) + go func(name, path string) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + size := calculateDirSizeConcurrent(path, tracker, filesScanned, dirsScanned, bytesScanned, currentPath) + atomic.AddInt64(&total, size) + atomic.AddInt64(dirsScanned, 1) + + entriesMu.Lock() + entries = append(entries, dirEntry{ + name: name, + path: path, + size: size, + isDir: true, + }) + entriesMu.Unlock() + }(child.Name(), fullPath) + continue + } + + info, err := child.Info() + if err != nil { + continue + } + size := info.Size() + atomic.AddInt64(&total, size) + atomic.AddInt64(filesScanned, 1) + atomic.AddInt64(bytesScanned, size) + + entries = append(entries, dirEntry{ + name: child.Name(), + path: fullPath, + size: size, + isDir: false, + }) + // Only track large files that are not code/text files + if !shouldSkipFileForLargeTracking(fullPath) { + tracker.add(fileEntry{name: child.Name(), path: fullPath, size: size}) + } + } + + wg.Wait() + + sort.Slice(entries, func(i, j int) bool { + return entries[i].size > entries[j].size + }) + if len(entries) > maxEntries { + entries = entries[:maxEntries] + } + + // Try to use Spotlight for faster large file discovery + var largeFiles []fileEntry + if spotlightFiles := findLargeFilesWithSpotlight(root, minLargeFileSize); len(spotlightFiles) > 0 { + largeFiles = spotlightFiles + } else { + // Fallback to manual tracking + largeFiles = tracker.list() + } + + return scanResult{ + entries: entries, + largeFiles: largeFiles, + totalSize: total, + }, nil +} + +func shouldFoldDir(name string) bool { + return foldDirs[name] +} + +func shouldSkipFileForLargeTracking(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + return skipExtensions[ext] +} + +// Fast directory size calculation (no detailed tracking, no large files) +func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned *int64) int64 { + var total int64 + + _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + atomic.AddInt64(dirsScanned, 1) + return nil + } + info, err := d.Info() + if err != nil { + return nil + } + size := info.Size() + total += size + atomic.AddInt64(filesScanned, 1) + atomic.AddInt64(bytesScanned, size) + return nil + }) + + return total +} + +// Use Spotlight (mdfind) to quickly find large files in a directory +func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry { + // mdfind query: files >= minSize in the specified directory + query := fmt.Sprintf("kMDItemFSSize >= %d", minSize) + + cmd := exec.Command("mdfind", "-onlyin", root, query) + output, err := cmd.Output() + if err != nil { + // Fallback: mdfind not available or failed + return nil + } + + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + var files []fileEntry + + for _, line := range lines { + if line == "" { + continue + } + + // Check if it's a directory, skip it + info, err := os.Stat(line) + if err != nil || info.IsDir() { + continue + } + + // Filter out files in folded directories + inFoldedDir := false + for foldDir := range foldDirs { + if strings.Contains(line, string(os.PathSeparator)+foldDir+string(os.PathSeparator)) || + strings.HasSuffix(filepath.Dir(line), string(os.PathSeparator)+foldDir) { + inFoldedDir = true + break + } + } + if inFoldedDir { + continue + } + + // Filter out code files + if shouldSkipFileForLargeTracking(line) { + continue + } + + files = append(files, fileEntry{ + name: filepath.Base(line), + path: line, + size: info.Size(), + }) + } + + // Sort by size (descending) + sort.Slice(files, func(i, j int) bool { + return files[i].size > files[j].size + }) + + // Return top N + if len(files) > maxLargeFiles { + files = files[:maxLargeFiles] + } + + return files +} + +func calculateDirSizeConcurrent(root string, tracker *largeFileTracker, filesScanned, dirsScanned, bytesScanned *int64, currentPath *string) int64 { + var total int64 + var updateCounter int64 + + _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + // Skip folded directories during recursive scanning + if shouldFoldDir(d.Name()) { + return filepath.SkipDir + } + atomic.AddInt64(dirsScanned, 1) + return nil + } + info, err := d.Info() + if err != nil { + return nil + } + size := info.Size() + total += size + atomic.AddInt64(filesScanned, 1) + atomic.AddInt64(bytesScanned, size) + + // Only track large files that are not code/text files + if !shouldSkipFileForLargeTracking(path) { + tracker.add(fileEntry{name: filepath.Base(path), path: path, size: size}) + } + + // Update current path every 100 files to reduce contention + updateCounter++ + if updateCounter%100 == 0 { + *currentPath = path + } + + return nil + }) + + return total +} + +type largeFileTracker struct { + mu sync.Mutex + entries []fileEntry +} + +func newLargeFileTracker() *largeFileTracker { + return &largeFileTracker{ + entries: make([]fileEntry, 0, maxLargeFiles), + } +} + +func (t *largeFileTracker) add(f fileEntry) { + if f.size < minLargeFileSize { + return + } + + t.mu.Lock() + defer t.mu.Unlock() + + t.entries = append(t.entries, f) + sort.Slice(t.entries, func(i, j int) bool { + return t.entries[i].size > t.entries[j].size + }) + if len(t.entries) > maxLargeFiles { + t.entries = t.entries[:maxLargeFiles] + } +} + +func (t *largeFileTracker) list() []fileEntry { + t.mu.Lock() + defer t.mu.Unlock() + return append([]fileEntry(nil), t.entries...) +} + +func displayPath(path string) string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return path + } + if strings.HasPrefix(path, home) { + return strings.Replace(path, home, "~", 1) + } + return path +} + +func formatNumber(n int64) string { + if n < 1000 { + return fmt.Sprintf("%d", n) + } + if n < 1000000 { + return fmt.Sprintf("%.1fk", float64(n)/1000) + } + return fmt.Sprintf("%.1fM", float64(n)/1000000) +} + +func humanizeBytes(size int64) string { + if size < 0 { + return "0 B" + } + const unit = 1024 + if size < unit { + return fmt.Sprintf("%d B", size) + } + div, exp := int64(unit), 0 + for n := size / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + value := float64(size) / float64(div) + return fmt.Sprintf("%.1f %cB", value, "KMGTPE"[exp]) +} + +func progressBar(value, max int64) string { + if max <= 0 { + return strings.Repeat("ā–‘", barWidth) + } + filled := int((value * int64(barWidth)) / max) + if filled > barWidth { + filled = barWidth + } + bar := strings.Repeat("ā–ˆ", filled) + if filled < barWidth { + bar += strings.Repeat("ā–‘", barWidth-filled) + } + return bar +} + +func coloredProgressBar(value, max int64, percent float64) string { + if max <= 0 { + return colorGray + strings.Repeat("ā–‘", barWidth) + colorReset + } + + filled := int((value * int64(barWidth)) / max) + if filled > barWidth { + filled = barWidth + } + + // Choose color based on percentage + var barColor string + if percent >= 50 { + barColor = colorRed // Large files in red + } else if percent >= 20 { + barColor = colorYellow // Medium files in yellow + } else if percent >= 5 { + barColor = colorCyan // Small-medium in cyan + } else { + barColor = colorGreen // Small files in green + } + + // Create gradient bar with different characters + bar := barColor + for i := 0; i < barWidth; i++ { + if i < filled { + if i < filled-1 { + bar += "ā–ˆ" + } else { + // Last filled character might be partial + remainder := (value * int64(barWidth)) % max + if remainder > max/2 { + bar += "ā–ˆ" + } else if remainder > max/4 { + bar += "ā–“" + } else { + bar += "ā–’" + } + } + } else { + bar += colorGray + "ā–‘" + barColor + } + } + bar += colorReset + + return bar +} + +// Calculate display width considering CJK characters +func runeWidth(r rune) int { + if r >= 0x4E00 && r <= 0x9FFF || // CJK Unified Ideographs + r >= 0x3400 && r <= 0x4DBF || // CJK Extension A + r >= 0xAC00 && r <= 0xD7AF || // Hangul + r >= 0xFF00 && r <= 0xFFEF { // Fullwidth forms + return 2 + } + return 1 +} + +func displayWidth(s string) int { + width := 0 + for _, r := range s { + width += runeWidth(r) + } + return width +} + +func trimName(name string) string { + const ( + maxWidth = 28 + ellipsis = "..." + ellipsisWidth = 3 + ) + + runes := []rune(name) + widths := make([]int, len(runes)) + for i, r := range runes { + widths[i] = runeWidth(r) + } + + currentWidth := 0 + for i, w := range widths { + if currentWidth+w > maxWidth { + subWidth := currentWidth + j := i + for j > 0 && subWidth+ellipsisWidth > maxWidth { + j-- + subWidth -= widths[j] + } + if j == 0 { + return ellipsis + } + return string(runes[:j]) + ellipsis + } + currentWidth += w + } + + return name +} + +func padName(name string, targetWidth int) string { + currentWidth := displayWidth(name) + if currentWidth >= targetWidth { + return name + } + return name + strings.Repeat(" ", targetWidth-currentWidth) +} + +func (m *model) clampEntrySelection() { + if len(m.entries) == 0 { + m.selected = 0 + m.offset = 0 + return + } + if m.selected >= len(m.entries) { + m.selected = len(m.entries) - 1 + } + if m.selected < 0 { + m.selected = 0 + } + maxOffset := len(m.entries) - entryViewport + if maxOffset < 0 { + maxOffset = 0 + } + if m.offset > maxOffset { + m.offset = maxOffset + } + if m.selected < m.offset { + m.offset = m.selected + } + if m.selected >= m.offset+entryViewport { + m.offset = m.selected - entryViewport + 1 + } +} + +func (m *model) clampLargeSelection() { + if len(m.largeFiles) == 0 { + m.largeSelected = 0 + m.largeOffset = 0 + return + } + if m.largeSelected >= len(m.largeFiles) { + m.largeSelected = len(m.largeFiles) - 1 + } + if m.largeSelected < 0 { + m.largeSelected = 0 + } + maxOffset := len(m.largeFiles) - largeViewport + if maxOffset < 0 { + maxOffset = 0 + } + if m.largeOffset > maxOffset { + m.largeOffset = maxOffset + } + if m.largeSelected < m.largeOffset { + m.largeOffset = m.largeSelected + } + if m.largeSelected >= m.largeOffset+largeViewport { + m.largeOffset = m.largeSelected - largeViewport + 1 + } +} + +func cloneDirEntries(entries []dirEntry) []dirEntry { + if len(entries) == 0 { + return nil + } + copied := make([]dirEntry, len(entries)) + copy(copied, entries) + return copied +} + +func cloneFileEntries(files []fileEntry) []fileEntry { + if len(files) == 0 { + return nil + } + copied := make([]fileEntry, len(files)) + copy(copied, files) + return copied +} + +func snapshotFromModel(m model) historyEntry { + return historyEntry{ + path: m.path, + entries: cloneDirEntries(m.entries), + largeFiles: cloneFileEntries(m.largeFiles), + totalSize: m.totalSize, + selected: m.selected, + entryOffset: m.offset, + largeSelected: m.largeSelected, + largeOffset: m.largeOffset, + } +} + +func cacheSnapshot(m model) historyEntry { + entry := snapshotFromModel(m) + entry.dirty = false + return entry +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..dc882fb --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module github.com/tw93/mole + +go 1.24.0 + +toolchain go1.24.6 + +require github.com/charmbracelet/bubbletea v1.3.10 + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4789639 --- /dev/null +++ b/go.sum @@ -0,0 +1,43 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=