mirror of
https://github.com/tw93/Mole.git
synced 2026-02-04 13:16:47 +00:00
1959 lines
61 KiB
Bash
Executable File
1959 lines
61 KiB
Bash
Executable File
#!/bin/bash
|
|
# Mole - Disk Space Analyzer Module
|
|
# Fast disk analysis with mdfind + du hybrid approach
|
|
|
|
set -euo pipefail
|
|
|
|
# Get script directory for sourcing libraries
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
LIB_DIR="$(dirname "$SCRIPT_DIR")/lib"
|
|
|
|
# Source required libraries
|
|
# shellcheck source=../lib/common.sh
|
|
source "$LIB_DIR/common.sh"
|
|
|
|
# Constants
|
|
readonly CACHE_DIR="${HOME}/.config/mole/cache"
|
|
readonly TEMP_PREFIX="/tmp/mole_analyze_$$"
|
|
readonly MIN_LARGE_FILE_SIZE="1000000000" # 1GB
|
|
readonly MIN_MEDIUM_FILE_SIZE="100000000" # 100MB
|
|
readonly MIN_SMALL_FILE_SIZE="10000000" # 10MB
|
|
|
|
# Global state
|
|
declare -a SCAN_RESULTS=()
|
|
declare -a DIR_RESULTS=()
|
|
declare -a LARGE_FILES=()
|
|
declare SCAN_PID=""
|
|
declare TOTAL_SIZE=0
|
|
declare CURRENT_PATH="$HOME"
|
|
declare CURRENT_DEPTH=1
|
|
|
|
# UI State
|
|
declare CURSOR_POS=0
|
|
declare SORT_MODE="size" # size, name, time
|
|
declare VIEW_MODE="overview" # overview, detail, files
|
|
|
|
# Cleanup on exit
|
|
cleanup() {
|
|
show_cursor
|
|
rm -f "$TEMP_PREFIX"* 2>/dev/null || true
|
|
if [[ -n "$SCAN_PID" ]] && kill -0 "$SCAN_PID" 2>/dev/null; then
|
|
kill "$SCAN_PID" 2>/dev/null || true
|
|
fi
|
|
}
|
|
|
|
trap cleanup EXIT INT TERM
|
|
|
|
# ============================================================================
|
|
# Scanning Functions
|
|
# ============================================================================
|
|
|
|
# Fast scan using mdfind for large files
|
|
scan_large_files() {
|
|
local target_path="$1"
|
|
local output_file="$2"
|
|
|
|
if ! command -v mdfind &>/dev/null; then
|
|
return 1
|
|
fi
|
|
|
|
# Scan files > 1GB
|
|
mdfind -onlyin "$target_path" "kMDItemFSSize > $MIN_LARGE_FILE_SIZE" 2>/dev/null | \
|
|
while IFS= read -r file; do
|
|
if [[ -f "$file" ]]; then
|
|
local size=$(stat -f%z "$file" 2>/dev/null || echo "0")
|
|
echo "$size|$file"
|
|
fi
|
|
done | sort -t'|' -k1 -rn > "$output_file"
|
|
}
|
|
|
|
# Scan medium files (100MB - 1GB)
|
|
scan_medium_files() {
|
|
local target_path="$1"
|
|
local output_file="$2"
|
|
|
|
if ! command -v mdfind &>/dev/null; then
|
|
return 1
|
|
fi
|
|
|
|
mdfind -onlyin "$target_path" \
|
|
"kMDItemFSSize > $MIN_MEDIUM_FILE_SIZE && kMDItemFSSize < $MIN_LARGE_FILE_SIZE" 2>/dev/null | \
|
|
while IFS= read -r file; do
|
|
if [[ -f "$file" ]]; then
|
|
local size=$(stat -f%z "$file" 2>/dev/null || echo "0")
|
|
echo "$size|$file"
|
|
fi
|
|
done | sort -t'|' -k1 -rn > "$output_file"
|
|
}
|
|
|
|
# Scan top-level directories with du (optimized with parallel)
|
|
scan_directories() {
|
|
local target_path="$1"
|
|
local output_file="$2"
|
|
local depth="${3:-1}"
|
|
|
|
# Check if we can use parallel processing
|
|
if command -v xargs &>/dev/null && [[ $depth -eq 1 ]]; then
|
|
# Fast parallel scan for depth 1
|
|
find "$target_path" -mindepth 1 -maxdepth 1 -type d -print0 2>/dev/null | \
|
|
xargs -0 -P 4 -I {} du -sk {} 2>/dev/null | \
|
|
sort -rn | \
|
|
while IFS=$'\t' read -r size path; do
|
|
echo "$((size * 1024))|$path"
|
|
done > "$output_file"
|
|
else
|
|
# Standard du scan
|
|
du -d "$depth" -k "$target_path" 2>/dev/null | \
|
|
sort -rn | \
|
|
while IFS=$'\t' read -r size path; do
|
|
# Skip if path is the target itself at depth > 0
|
|
if [[ "$path" != "$target_path" ]]; then
|
|
echo "$((size * 1024))|$path"
|
|
fi
|
|
done > "$output_file"
|
|
fi
|
|
}
|
|
|
|
# Aggregate files by directory
|
|
aggregate_by_directory() {
|
|
local file_list="$1"
|
|
local output_file="$2"
|
|
|
|
awk -F'|' '{
|
|
path = $2
|
|
size = $1
|
|
# Get parent directory
|
|
n = split(path, parts, "/")
|
|
dir = ""
|
|
for(i=1; i<n; i++) {
|
|
dir = dir parts[i] "/"
|
|
}
|
|
if(dir) {
|
|
dir_count[dir]++
|
|
dir_size[dir] += size
|
|
}
|
|
}
|
|
END {
|
|
for(dir in dir_count) {
|
|
printf "%d|%s|%d\n", dir_size[dir], dir, dir_count[dir]
|
|
}
|
|
}' "$file_list" | sort -t'|' -k1 -rn > "$output_file"
|
|
}
|
|
|
|
# Get cache file path for a directory
|
|
get_cache_file() {
|
|
local target_path="$1"
|
|
local path_hash=$(echo "$target_path" | md5 2>/dev/null || echo "$target_path" | shasum | cut -d' ' -f1)
|
|
echo "$CACHE_DIR/scan_${path_hash}.cache"
|
|
}
|
|
|
|
# Check if cache is valid (less than 1 hour old)
|
|
is_cache_valid() {
|
|
local cache_file="$1"
|
|
local max_age="${2:-3600}" # Default 1 hour
|
|
|
|
if [[ ! -f "$cache_file" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
local cache_age=$(($(date +%s) - $(stat -f%m "$cache_file" 2>/dev/null || echo 0)))
|
|
if [[ $cache_age -lt $max_age ]]; then
|
|
return 0
|
|
fi
|
|
|
|
return 1
|
|
}
|
|
|
|
# Save scan results to cache
|
|
save_to_cache() {
|
|
local cache_file="$1"
|
|
local temp_large="$TEMP_PREFIX.large"
|
|
local temp_medium="$TEMP_PREFIX.medium"
|
|
local temp_dirs="$TEMP_PREFIX.dirs"
|
|
local temp_agg="$TEMP_PREFIX.agg"
|
|
|
|
# Create cache directory
|
|
mkdir -p "$(dirname "$cache_file")" 2>/dev/null || return 1
|
|
|
|
# Bundle all scan results into cache file
|
|
{
|
|
echo "### LARGE ###"
|
|
[[ -f "$temp_large" ]] && cat "$temp_large"
|
|
echo "### MEDIUM ###"
|
|
[[ -f "$temp_medium" ]] && cat "$temp_medium"
|
|
echo "### DIRS ###"
|
|
[[ -f "$temp_dirs" ]] && cat "$temp_dirs"
|
|
echo "### AGG ###"
|
|
[[ -f "$temp_agg" ]] && cat "$temp_agg"
|
|
} > "$cache_file" 2>/dev/null
|
|
}
|
|
|
|
# Load scan results from cache
|
|
load_from_cache() {
|
|
local cache_file="$1"
|
|
local temp_large="$TEMP_PREFIX.large"
|
|
local temp_medium="$TEMP_PREFIX.medium"
|
|
local temp_dirs="$TEMP_PREFIX.dirs"
|
|
local temp_agg="$TEMP_PREFIX.agg"
|
|
|
|
local section=""
|
|
while IFS= read -r line; do
|
|
case "$line" in
|
|
"### LARGE ###") section="large" ;;
|
|
"### MEDIUM ###") section="medium" ;;
|
|
"### DIRS ###") section="dirs" ;;
|
|
"### AGG ###") section="agg" ;;
|
|
*)
|
|
case "$section" in
|
|
"large") echo "$line" >> "$temp_large" ;;
|
|
"medium") echo "$line" >> "$temp_medium" ;;
|
|
"dirs") echo "$line" >> "$temp_dirs" ;;
|
|
"agg") echo "$line" >> "$temp_agg" ;;
|
|
esac
|
|
;;
|
|
esac
|
|
done < "$cache_file"
|
|
}
|
|
|
|
# Main scan coordinator
|
|
perform_scan() {
|
|
local target_path="$1"
|
|
local force_rescan="${2:-false}"
|
|
|
|
# Check cache first
|
|
local cache_file=$(get_cache_file "$target_path")
|
|
if [[ "$force_rescan" != "true" ]] && is_cache_valid "$cache_file" 3600; then
|
|
log_info "Loading cached results for $target_path..."
|
|
load_from_cache "$cache_file"
|
|
log_success "Cache loaded!"
|
|
return 0
|
|
fi
|
|
|
|
log_info "Analyzing disk space in $target_path..."
|
|
echo ""
|
|
|
|
# Create temp files
|
|
local temp_large="$TEMP_PREFIX.large"
|
|
local temp_medium="$TEMP_PREFIX.medium"
|
|
local temp_dirs="$TEMP_PREFIX.dirs"
|
|
local temp_agg="$TEMP_PREFIX.agg"
|
|
|
|
# Start parallel scans
|
|
{
|
|
scan_large_files "$target_path" "$temp_large" &
|
|
scan_medium_files "$target_path" "$temp_medium" &
|
|
scan_directories "$target_path" "$temp_dirs" "$CURRENT_DEPTH" &
|
|
wait
|
|
} &
|
|
SCAN_PID=$!
|
|
|
|
# Show spinner with progress while scanning
|
|
local spinner_chars="⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
local i=0
|
|
local elapsed=0
|
|
hide_cursor
|
|
|
|
# Progress messages (short and dynamic)
|
|
local messages=(
|
|
"Finding large files"
|
|
"Scanning directories"
|
|
"Calculating sizes"
|
|
"Finishing up"
|
|
)
|
|
local msg_idx=0
|
|
|
|
while kill -0 "$SCAN_PID" 2>/dev/null; do
|
|
# Show different messages based on elapsed time
|
|
local current_msg=""
|
|
if [[ $elapsed -lt 5 ]]; then
|
|
current_msg="${messages[0]}"
|
|
elif [[ $elapsed -lt 15 ]]; then
|
|
current_msg="${messages[1]}"
|
|
elif [[ $elapsed -lt 25 ]]; then
|
|
current_msg="${messages[2]}"
|
|
else
|
|
current_msg="${messages[3]}"
|
|
fi
|
|
|
|
printf "\r${BLUE}%s${NC} %s" \
|
|
"${spinner_chars:$i:1}" "$current_msg"
|
|
|
|
i=$(( (i + 1) % 10 ))
|
|
((elapsed++))
|
|
sleep 0.1
|
|
done
|
|
wait "$SCAN_PID" 2>/dev/null || true
|
|
printf "\r%80s\r" "" # Clear spinner line
|
|
show_cursor
|
|
|
|
# Aggregate results
|
|
if [[ -f "$temp_large" ]] && [[ -s "$temp_large" ]]; then
|
|
aggregate_by_directory "$temp_large" "$temp_agg"
|
|
fi
|
|
|
|
# Save to cache
|
|
save_to_cache "$cache_file"
|
|
|
|
log_success "Scan complete!"
|
|
}
|
|
|
|
# ============================================================================
|
|
# Visualization Functions
|
|
# ============================================================================
|
|
|
|
# Generate progress bar
|
|
generate_bar() {
|
|
local current="$1"
|
|
local max="$2"
|
|
local width="${3:-20}"
|
|
|
|
if [[ "$max" -eq 0 ]]; then
|
|
printf "%${width}s" "" | tr ' ' '░'
|
|
return
|
|
fi
|
|
|
|
local filled=$((current * width / max))
|
|
local empty=$((width - filled))
|
|
|
|
# Ensure non-negative
|
|
[[ $filled -lt 0 ]] && filled=0
|
|
[[ $empty -lt 0 ]] && empty=0
|
|
|
|
local bar=""
|
|
if [[ $filled -gt 0 ]]; then
|
|
bar=$(printf "%${filled}s" "" | tr ' ' '█')
|
|
fi
|
|
if [[ $empty -gt 0 ]]; then
|
|
bar="${bar}$(printf "%${empty}s" "" | tr ' ' '░')"
|
|
fi
|
|
|
|
echo "$bar"
|
|
}
|
|
|
|
# Calculate percentage
|
|
calc_percentage() {
|
|
local part="$1"
|
|
local total="$2"
|
|
|
|
if [[ "$total" -eq 0 ]]; then
|
|
echo "0"
|
|
return
|
|
fi
|
|
|
|
echo "$part" "$total" | awk '{printf "%.1f", ($1/$2)*100}'
|
|
}
|
|
|
|
# Display large files summary (compact version)
|
|
display_large_files_compact() {
|
|
local temp_large="$TEMP_PREFIX.large"
|
|
|
|
if [[ ! -f "$temp_large" ]] || [[ ! -s "$temp_large" ]]; then
|
|
return
|
|
fi
|
|
|
|
log_header "📊 Top Large Files"
|
|
echo ""
|
|
|
|
local count=0
|
|
local total_size=0
|
|
local total_count=$(wc -l < "$temp_large" | tr -d ' ')
|
|
|
|
# Calculate total size
|
|
while IFS='|' read -r size path; do
|
|
((total_size += size))
|
|
done < "$temp_large"
|
|
|
|
# Show top 5 only
|
|
while IFS='|' read -r size path; do
|
|
if [[ $count -ge 5 ]]; then
|
|
break
|
|
fi
|
|
|
|
local human_size=$(bytes_to_human "$size")
|
|
local filename=$(basename "$path")
|
|
local dirname=$(basename "$(dirname "$path")")
|
|
|
|
printf " ${GREEN}%-8s${NC} 📄 %-40s ${GRAY}%s${NC}\n" \
|
|
"$human_size" "${filename:0:40}" "$dirname"
|
|
|
|
((count++))
|
|
done < "$temp_large"
|
|
|
|
echo ""
|
|
local total_human=$(bytes_to_human "$total_size")
|
|
echo " ${GRAY}Found $total_count large files (>1GB), totaling $total_human${NC}"
|
|
echo ""
|
|
}
|
|
|
|
# Display large files summary (full version)
|
|
display_large_files() {
|
|
local temp_large="$TEMP_PREFIX.large"
|
|
|
|
if [[ ! -f "$temp_large" ]] || [[ ! -s "$temp_large" ]]; then
|
|
log_header "📊 Large Files (>1GB)"
|
|
echo ""
|
|
echo " ${GRAY}No files larger than 1GB found${NC}"
|
|
echo ""
|
|
return
|
|
fi
|
|
|
|
log_header "📊 Large Files (>1GB)"
|
|
echo ""
|
|
|
|
local count=0
|
|
local max_size=0
|
|
|
|
# Get max size for progress bar
|
|
max_size=$(head -1 "$temp_large" | cut -d'|' -f1)
|
|
[[ -z "$max_size" ]] && max_size=1
|
|
|
|
while IFS='|' read -r size path; do
|
|
if [[ $count -ge 10 ]]; then
|
|
break
|
|
fi
|
|
|
|
local human_size=$(bytes_to_human "$size")
|
|
local percentage=$(calc_percentage "$size" "$max_size")
|
|
local bar=$(generate_bar "$size" "$max_size" 20)
|
|
local filename=$(basename "$path")
|
|
local dirname=$(dirname "$path" | sed "s|^$HOME|~|")
|
|
|
|
printf " %s [${GREEN}%s${NC}] %7s\n" "$bar" "$human_size" ""
|
|
printf " 📄 %s\n" "$filename"
|
|
printf " ${GRAY}%s${NC}\n\n" "$dirname"
|
|
|
|
((count++))
|
|
done < "$temp_large"
|
|
|
|
# Show total count
|
|
local total_count=$(wc -l < "$temp_large" | tr -d ' ')
|
|
if [[ $total_count -gt 10 ]]; then
|
|
echo " ${GRAY}... and $((total_count - 10)) more files${NC}"
|
|
echo ""
|
|
fi
|
|
}
|
|
|
|
# Display directory summary (compact version)
|
|
display_directories_compact() {
|
|
local temp_dirs="$TEMP_PREFIX.dirs"
|
|
|
|
if [[ ! -f "$temp_dirs" ]] || [[ ! -s "$temp_dirs" ]]; then
|
|
return
|
|
fi
|
|
|
|
log_header "📁 Top Directories"
|
|
echo ""
|
|
|
|
local count=0
|
|
local total_size=0
|
|
|
|
# Calculate total
|
|
while IFS='|' read -r size path; do
|
|
((total_size += size))
|
|
done < "$temp_dirs"
|
|
[[ $total_size -eq 0 ]] && total_size=1
|
|
|
|
# Show top 8 directories in compact format
|
|
while IFS='|' read -r size path; do
|
|
if [[ $count -ge 8 ]]; then
|
|
break
|
|
fi
|
|
|
|
local human_size=$(bytes_to_human "$size")
|
|
local percentage=$(calc_percentage "$size" "$total_size")
|
|
local dirname=$(basename "$path")
|
|
|
|
# Simple bar (10 chars)
|
|
local bar_width=10
|
|
local percentage_int=${percentage%.*} # Remove decimal part
|
|
local filled=$((percentage_int * bar_width / 100))
|
|
[[ $filled -gt $bar_width ]] && filled=$bar_width
|
|
[[ $filled -lt 0 ]] && filled=0
|
|
local empty=$((bar_width - filled))
|
|
[[ $empty -lt 0 ]] && empty=0
|
|
local bar=""
|
|
if [[ $filled -gt 0 ]]; then
|
|
bar=$(printf "%${filled}s" "" | tr ' ' '█')
|
|
fi
|
|
if [[ $empty -gt 0 ]]; then
|
|
bar="${bar}$(printf "%${empty}s" "" | tr ' ' '░')"
|
|
fi
|
|
|
|
printf " ${BLUE}%-8s${NC} %s ${GRAY}%3s%%${NC} 📁 %s\n" \
|
|
"$human_size" "$bar" "$percentage" "$dirname"
|
|
|
|
((count++))
|
|
done < "$temp_dirs"
|
|
echo ""
|
|
}
|
|
|
|
# Display directory summary (full version)
|
|
display_directories() {
|
|
local temp_dirs="$TEMP_PREFIX.dirs"
|
|
|
|
if [[ ! -f "$temp_dirs" ]] || [[ ! -s "$temp_dirs" ]]; then
|
|
return
|
|
fi
|
|
|
|
log_header "📁 Top Directories"
|
|
echo ""
|
|
|
|
local count=0
|
|
local max_size=0
|
|
local total_size=0
|
|
|
|
# Calculate total and max for percentages
|
|
max_size=$(head -1 "$temp_dirs" | cut -d'|' -f1)
|
|
[[ -z "$max_size" ]] && max_size=1
|
|
|
|
while IFS='|' read -r size path; do
|
|
((total_size += size))
|
|
done < "$temp_dirs"
|
|
|
|
[[ $total_size -eq 0 ]] && total_size=1
|
|
|
|
# Display directories
|
|
while IFS='|' read -r size path; do
|
|
if [[ $count -ge 15 ]]; then
|
|
break
|
|
fi
|
|
|
|
local human_size=$(bytes_to_human "$size")
|
|
local percentage=$(calc_percentage "$size" "$total_size")
|
|
local bar=$(generate_bar "$size" "$max_size" 20)
|
|
local display_path=$(echo "$path" | sed "s|^$HOME|~|")
|
|
local dirname=$(basename "$path")
|
|
|
|
printf " %s [${BLUE}%s${NC}] %5s%%\n" "$bar" "$human_size" "$percentage"
|
|
printf " 📁 %s\n\n" "$display_path"
|
|
|
|
((count++))
|
|
done < "$temp_dirs"
|
|
}
|
|
|
|
# Display hotspot directories (many large files)
|
|
display_hotspots() {
|
|
local temp_agg="$TEMP_PREFIX.agg"
|
|
|
|
if [[ ! -f "$temp_agg" ]] || [[ ! -s "$temp_agg" ]]; then
|
|
return
|
|
fi
|
|
|
|
log_header "🔥 Hotspot Directories (High File Concentration)"
|
|
echo ""
|
|
|
|
local count=0
|
|
while IFS='|' read -r size path file_count; do
|
|
if [[ $count -ge 8 ]]; then
|
|
break
|
|
fi
|
|
|
|
local human_size=$(bytes_to_human "$size")
|
|
local display_path=$(echo "$path" | sed "s|^$HOME|~|")
|
|
|
|
printf " 📍 %s\n" "$display_path"
|
|
printf " ${GREEN}%s${NC} in ${YELLOW}%d${NC} large files\n\n" \
|
|
"$human_size" "$file_count"
|
|
|
|
((count++))
|
|
done < "$temp_agg"
|
|
}
|
|
|
|
# Display smart cleanup suggestions (compact version)
|
|
display_cleanup_suggestions_compact() {
|
|
local suggestions_count=0
|
|
local top_suggestion=""
|
|
local potential_space=0
|
|
local action_command=""
|
|
|
|
# Check common cache locations (only if analyzing Library/Caches or system paths)
|
|
if [[ "$CURRENT_PATH" == "$HOME/Library/Caches"* ]] || [[ "$CURRENT_PATH" == "$HOME/Library"* ]]; then
|
|
if [[ -d "$HOME/Library/Caches" ]]; then
|
|
local cache_size=$(du -sk "$HOME/Library/Caches" 2>/dev/null | cut -f1)
|
|
if [[ $cache_size -gt 1048576 ]]; then # > 1GB
|
|
local human=$(bytes_to_human $((cache_size * 1024)))
|
|
top_suggestion="Clear app caches ($human)"
|
|
action_command="mole clean"
|
|
((potential_space += cache_size * 1024))
|
|
((suggestions_count++))
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# Check Downloads folder (only if analyzing Downloads)
|
|
if [[ "$CURRENT_PATH" == "$HOME/Downloads"* ]]; then
|
|
local old_files=$(find "$CURRENT_PATH" -type f -mtime +90 2>/dev/null | wc -l | tr -d ' ')
|
|
if [[ $old_files -gt 0 ]]; then
|
|
[[ -z "$top_suggestion" ]] && top_suggestion="$old_files files older than 90 days found"
|
|
[[ -z "$action_command" ]] && action_command="manually review old files"
|
|
((suggestions_count++))
|
|
fi
|
|
fi
|
|
|
|
# Check for large disk images in current path
|
|
if command -v mdfind &>/dev/null; then
|
|
local dmg_count=$(mdfind -onlyin "$CURRENT_PATH" \
|
|
"kMDItemFSSize > 500000000 && kMDItemDisplayName == '*.dmg'" 2>/dev/null | wc -l | tr -d ' ')
|
|
if [[ $dmg_count -gt 0 ]]; then
|
|
local dmg_size=$(mdfind -onlyin "$CURRENT_PATH" \
|
|
"kMDItemFSSize > 500000000 && kMDItemDisplayName == '*.dmg'" 2>/dev/null | \
|
|
xargs stat -f%z 2>/dev/null | awk '{sum+=$1} END {print sum}')
|
|
local dmg_human=$(bytes_to_human "$dmg_size")
|
|
[[ -z "$top_suggestion" ]] && top_suggestion="$dmg_count DMG files ($dmg_human) can be removed"
|
|
[[ -z "$action_command" ]] && action_command="manually delete DMG files"
|
|
((potential_space += dmg_size))
|
|
((suggestions_count++))
|
|
fi
|
|
fi
|
|
|
|
# Check Xcode (only if in developer paths)
|
|
if [[ "$CURRENT_PATH" == "$HOME/Library/Developer"* ]] && [[ -d "$HOME/Library/Developer/Xcode/DerivedData" ]]; then
|
|
local xcode_size=$(du -sk "$HOME/Library/Developer/Xcode/DerivedData" 2>/dev/null | cut -f1)
|
|
if [[ $xcode_size -gt 10485760 ]]; then
|
|
local xcode_human=$(bytes_to_human $((xcode_size * 1024)))
|
|
[[ -z "$top_suggestion" ]] && top_suggestion="Xcode cache ($xcode_human) can be cleared"
|
|
[[ -z "$action_command" ]] && action_command="mole clean"
|
|
((potential_space += xcode_size * 1024))
|
|
((suggestions_count++))
|
|
fi
|
|
fi
|
|
|
|
# Check for duplicates in current path
|
|
if command -v mdfind &>/dev/null; then
|
|
local dup_count=$(mdfind -onlyin "$CURRENT_PATH" "kMDItemFSSize > 10000000" 2>/dev/null | \
|
|
xargs -I {} stat -f "%z" {} 2>/dev/null | sort | uniq -d | wc -l | tr -d ' ')
|
|
if [[ $dup_count -gt 5 ]]; then
|
|
[[ -z "$top_suggestion" ]] && top_suggestion="$dup_count potential duplicate files detected"
|
|
((suggestions_count++))
|
|
fi
|
|
fi
|
|
|
|
if [[ $suggestions_count -gt 0 ]]; then
|
|
log_header "💡 Quick Insights"
|
|
echo ""
|
|
echo " ${YELLOW}✨ $top_suggestion${NC}"
|
|
if [[ $suggestions_count -gt 1 ]]; then
|
|
echo " ${GRAY}... and $((suggestions_count - 1)) more insights${NC}"
|
|
fi
|
|
if [[ $potential_space -gt 0 ]]; then
|
|
local space_human=$(bytes_to_human "$potential_space")
|
|
echo " ${GREEN}Potential recovery: ~$space_human${NC}"
|
|
fi
|
|
echo ""
|
|
if [[ -n "$action_command" ]]; then
|
|
if [[ "$action_command" == "mole clean" ]]; then
|
|
echo " ${GRAY}→ Run${NC} ${YELLOW}mole clean${NC} ${GRAY}to cleanup system files${NC}"
|
|
else
|
|
echo " ${GRAY}→ Review and ${NC}${YELLOW}$action_command${NC}"
|
|
fi
|
|
fi
|
|
echo ""
|
|
fi
|
|
}
|
|
|
|
# Display smart cleanup suggestions (full version)
|
|
display_cleanup_suggestions() {
|
|
log_header "💡 Smart Cleanup Suggestions"
|
|
echo ""
|
|
|
|
local suggestions=()
|
|
|
|
# Check common cache locations
|
|
if [[ -d "$HOME/Library/Caches" ]]; then
|
|
local cache_size=$(du -sk "$HOME/Library/Caches" 2>/dev/null | cut -f1)
|
|
if [[ $cache_size -gt 1048576 ]]; then # > 1GB
|
|
local human=$(bytes_to_human $((cache_size * 1024)))
|
|
suggestions+=(" 🗑️ Clear application caches: $human")
|
|
fi
|
|
fi
|
|
|
|
# Check Downloads folder
|
|
if [[ -d "$HOME/Downloads" ]]; then
|
|
local old_files=$(find "$HOME/Downloads" -type f -mtime +90 2>/dev/null | wc -l | tr -d ' ')
|
|
if [[ $old_files -gt 0 ]]; then
|
|
suggestions+=(" 📥 Clean old downloads: $old_files files older than 90 days")
|
|
fi
|
|
fi
|
|
|
|
# Check for large disk images
|
|
if command -v mdfind &>/dev/null; then
|
|
local dmg_count=$(mdfind -onlyin "$HOME" \
|
|
"kMDItemFSSize > 500000000 && kMDItemDisplayName == '*.dmg'" 2>/dev/null | wc -l | tr -d ' ')
|
|
if [[ $dmg_count -gt 0 ]]; then
|
|
suggestions+=(" 💿 Remove disk images: $dmg_count DMG files >500MB")
|
|
fi
|
|
fi
|
|
|
|
# Check Xcode derived data
|
|
if [[ -d "$HOME/Library/Developer/Xcode/DerivedData" ]]; then
|
|
local xcode_size=$(du -sk "$HOME/Library/Developer/Xcode/DerivedData" 2>/dev/null | cut -f1)
|
|
if [[ $xcode_size -gt 10485760 ]]; then # > 10GB
|
|
local human=$(bytes_to_human $((xcode_size * 1024)))
|
|
suggestions+=(" 🔨 Clear Xcode cache: $human")
|
|
fi
|
|
fi
|
|
|
|
# Check iOS device backups
|
|
if [[ -d "$HOME/Library/Application Support/MobileSync/Backup" ]]; then
|
|
local backup_size=$(du -sk "$HOME/Library/Application Support/MobileSync/Backup" 2>/dev/null | cut -f1)
|
|
if [[ $backup_size -gt 5242880 ]]; then # > 5GB
|
|
local human=$(bytes_to_human $((backup_size * 1024)))
|
|
suggestions+=(" 📱 Review iOS backups: $human")
|
|
fi
|
|
fi
|
|
|
|
# Check for duplicate files (by size, quick heuristic)
|
|
if command -v mdfind &>/dev/null; then
|
|
local temp_dup="$TEMP_PREFIX.dup_check"
|
|
mdfind -onlyin "$CURRENT_PATH" "kMDItemFSSize > 10000000" 2>/dev/null | \
|
|
xargs -I {} stat -f "%z" {} 2>/dev/null | \
|
|
sort | uniq -d | wc -l | tr -d ' ' > "$temp_dup" 2>/dev/null || echo "0" > "$temp_dup"
|
|
local dup_count=$(cat "$temp_dup" 2>/dev/null || echo "0")
|
|
if [[ $dup_count -gt 5 ]]; then
|
|
suggestions+=(" 📋 Possible duplicates: $dup_count size matches in large files (>10MB)")
|
|
fi
|
|
fi
|
|
|
|
# Display suggestions
|
|
if [[ ${#suggestions[@]} -gt 0 ]]; then
|
|
printf '%s\n' "${suggestions[@]}"
|
|
echo ""
|
|
echo " ${YELLOW}Tip:${NC} Run 'mole clean' to perform cleanup operations"
|
|
else
|
|
echo " ${GREEN}✓${NC} No obvious cleanup opportunities found"
|
|
fi
|
|
echo ""
|
|
}
|
|
|
|
# Display overall disk situation summary
|
|
display_disk_summary() {
|
|
local temp_large="$TEMP_PREFIX.large"
|
|
local temp_dirs="$TEMP_PREFIX.dirs"
|
|
|
|
# Calculate stats
|
|
local total_large_size=0
|
|
local total_large_count=0
|
|
local total_dirs_size=0
|
|
local total_dirs_count=0
|
|
|
|
if [[ -f "$temp_large" ]]; then
|
|
total_large_count=$(wc -l < "$temp_large" 2>/dev/null | tr -d ' ')
|
|
while IFS='|' read -r size path; do
|
|
((total_large_size += size))
|
|
done < "$temp_large"
|
|
fi
|
|
|
|
if [[ -f "$temp_dirs" ]]; then
|
|
total_dirs_count=$(wc -l < "$temp_dirs" 2>/dev/null | tr -d ' ')
|
|
while IFS='|' read -r size path; do
|
|
((total_dirs_size += size))
|
|
done < "$temp_dirs"
|
|
fi
|
|
|
|
log_header "💾 Disk Situation"
|
|
|
|
local target_display=$(echo "$CURRENT_PATH" | sed "s|^$HOME|~|")
|
|
echo " ${BLUE}Scanning:${NC} $target_display | ${BLUE}Free:${NC} $(get_free_space)"
|
|
|
|
if [[ $total_large_count -gt 0 ]]; then
|
|
local large_human=$(bytes_to_human "$total_large_size")
|
|
echo " ${BLUE}Large Files:${NC} $total_large_count files ($large_human) | ${BLUE}Total:${NC} $(bytes_to_human "$total_dirs_size") in $total_dirs_count dirs"
|
|
elif [[ $total_dirs_size -gt 0 ]]; then
|
|
echo " ${BLUE}Total Scanned:${NC} $(bytes_to_human "$total_dirs_size") across $total_dirs_count directories"
|
|
fi
|
|
echo ""
|
|
}
|
|
|
|
# Get file type icon and description
|
|
get_file_info() {
|
|
local path="$1"
|
|
local ext="${path##*.}"
|
|
local icon=""
|
|
local type=""
|
|
|
|
case "$ext" in
|
|
dmg|iso|pkg) icon="📦" ; type="Installer" ;;
|
|
mov|mp4|avi|mkv|webm) icon="🎬" ; type="Video" ;;
|
|
zip|tar|gz|rar|7z) icon="🗜️" ; type="Archive" ;;
|
|
pdf) icon="📄" ; type="Document" ;;
|
|
jpg|jpeg|png|gif|heic) icon="🖼️" ; type="Image" ;;
|
|
key|ppt|pptx) icon="📊" ; type="Slides" ;;
|
|
log) icon="📝" ; type="Log" ;;
|
|
app) icon="⚙️" ; type="App" ;;
|
|
*) icon="📄" ; type="File" ;;
|
|
esac
|
|
|
|
echo "$icon|$type"
|
|
}
|
|
|
|
# Get file age in human readable format
|
|
get_file_age() {
|
|
local path="$1"
|
|
if [[ ! -f "$path" ]]; then
|
|
echo "N/A"
|
|
return
|
|
fi
|
|
|
|
local mtime=$(stat -f%m "$path" 2>/dev/null || echo "0")
|
|
local now=$(date +%s)
|
|
local diff=$((now - mtime))
|
|
local days=$((diff / 86400))
|
|
|
|
if [[ $days -lt 1 ]]; then
|
|
echo "Today"
|
|
elif [[ $days -eq 1 ]]; then
|
|
echo "1 day"
|
|
elif [[ $days -lt 30 ]]; then
|
|
echo "${days}d"
|
|
elif [[ $days -lt 365 ]]; then
|
|
local months=$((days / 30))
|
|
echo "${months}mo"
|
|
else
|
|
local years=$((days / 365))
|
|
echo "${years}yr"
|
|
fi
|
|
}
|
|
|
|
# Display large files in compact table format
|
|
display_large_files_table() {
|
|
local temp_large="$TEMP_PREFIX.large"
|
|
|
|
if [[ ! -f "$temp_large" ]] || [[ ! -s "$temp_large" ]]; then
|
|
return
|
|
fi
|
|
|
|
log_header "🎯 What's Taking Up Space"
|
|
|
|
# Table header
|
|
printf " %-4s %-10s %-8s %s\n" "TYPE" "SIZE" "AGE" "FILE"
|
|
printf " %s\n" "$(printf '%.0s─' {1..80})"
|
|
|
|
local count=0
|
|
while IFS='|' read -r size path; do
|
|
if [[ $count -ge 20 ]]; then
|
|
break
|
|
fi
|
|
|
|
local human_size=$(bytes_to_human "$size")
|
|
local filename=$(basename "$path")
|
|
local ext="${filename##*.}"
|
|
local age=$(get_file_age "$path")
|
|
|
|
# Get file info
|
|
local info=$(get_file_info "$path")
|
|
local icon="${info%|*}"
|
|
|
|
# Truncate filename if too long
|
|
if [[ ${#filename} -gt 50 ]]; then
|
|
filename="${filename:0:47}..."
|
|
fi
|
|
|
|
# Color based on file type
|
|
local color=""
|
|
case "$ext" in
|
|
dmg|iso|pkg) color="${RED}" ;;
|
|
mov|mp4|avi|mkv|webm|zip|tar|gz|rar|7z) color="${YELLOW}" ;;
|
|
log) color="${GRAY}" ;;
|
|
*) color="${NC}" ;;
|
|
esac
|
|
|
|
printf " %b%-4s %-10s %-8s %s${NC}\n" \
|
|
"$color" "$icon" "$human_size" "$age" "$filename"
|
|
|
|
((count++))
|
|
done < "$temp_large"
|
|
|
|
local total=$(wc -l < "$temp_large" | tr -d ' ')
|
|
if [[ $total -gt 20 ]]; then
|
|
echo " ${GRAY}... $((total - 20)) more files${NC}"
|
|
fi
|
|
echo ""
|
|
}
|
|
|
|
# Display unified directory view in table format
|
|
display_unified_directories() {
|
|
local temp_dirs="$TEMP_PREFIX.dirs"
|
|
|
|
if [[ ! -f "$temp_dirs" ]] || [[ ! -s "$temp_dirs" ]]; then
|
|
return
|
|
fi
|
|
|
|
# Calculate total
|
|
local total_size=0
|
|
while IFS='|' read -r size path; do
|
|
((total_size += size))
|
|
done < "$temp_dirs"
|
|
[[ $total_size -eq 0 ]] && total_size=1
|
|
|
|
echo " ${YELLOW}Top Directories:${NC}"
|
|
|
|
# Table header
|
|
printf " %-30s %5s %10s %s\n" "DIRECTORY" "%" "SIZE" "CHART"
|
|
printf " %s\n" "$(printf '%.0s─' {1..75})"
|
|
|
|
# Show top 10 directories
|
|
local count=0
|
|
local chart_width=20
|
|
while IFS='|' read -r size path; do
|
|
if [[ $count -ge 10 ]]; then
|
|
break
|
|
fi
|
|
|
|
local percentage=$((size * 100 / total_size))
|
|
local bar_width=$((percentage * chart_width / 100))
|
|
[[ $bar_width -lt 1 ]] && bar_width=1
|
|
|
|
local dirname=$(basename "$path")
|
|
local human_size=$(bytes_to_human "$size")
|
|
|
|
# Build compact bar
|
|
local bar=""
|
|
if [[ $bar_width -gt 0 ]]; then
|
|
bar=$(printf "%${bar_width}s" "" | tr ' ' '▓')
|
|
fi
|
|
local empty=$((chart_width - bar_width))
|
|
if [[ $empty -gt 0 ]]; then
|
|
bar="${bar}$(printf "%${empty}s" "" | tr ' ' '░')"
|
|
fi
|
|
|
|
# Truncate dirname if too long
|
|
local display_name="$dirname"
|
|
if [[ ${#dirname} -gt 28 ]]; then
|
|
display_name="${dirname:0:25}..."
|
|
fi
|
|
|
|
# Color based on percentage
|
|
local color="${NC}"
|
|
if [[ $percentage -gt 50 ]]; then
|
|
color="${RED}"
|
|
elif [[ $percentage -gt 20 ]]; then
|
|
color="${YELLOW}"
|
|
else
|
|
color="${BLUE}"
|
|
fi
|
|
|
|
printf " %b%-30s %4d%% %10s %s${NC}\n" \
|
|
"$color" "$display_name" "$percentage" "$human_size" "$bar"
|
|
|
|
((count++))
|
|
done < "$temp_dirs"
|
|
echo ""
|
|
}
|
|
|
|
# Display context-aware recommendations
|
|
display_recommendations() {
|
|
echo " ${YELLOW}💡 Quick Actions:${NC}"
|
|
|
|
if [[ "$CURRENT_PATH" == "$HOME/Downloads"* ]]; then
|
|
echo " → Delete ${RED}[Can Delete]${NC} items (installers/DMG)"
|
|
echo " → Review ${YELLOW}[Review]${NC} items (videos/archives)"
|
|
elif [[ "$CURRENT_PATH" == "$HOME/Library"* ]]; then
|
|
echo " → Run ${GREEN}mole clean${NC} to clear caches safely"
|
|
echo " → Check Xcode/developer caches if applicable"
|
|
else
|
|
echo " → Review ${RED}[Can Delete]${NC} and ${YELLOW}[Review]${NC} items"
|
|
echo " → Run ${GREEN}mole analyze ~/Library${NC} to check caches"
|
|
fi
|
|
echo ""
|
|
}
|
|
|
|
# Display space chart (visual tree map style)
|
|
display_space_chart() {
|
|
local temp_dirs="$TEMP_PREFIX.dirs"
|
|
|
|
if [[ ! -f "$temp_dirs" ]] || [[ ! -s "$temp_dirs" ]]; then
|
|
return
|
|
fi
|
|
|
|
log_header "📊 Space Distribution"
|
|
echo ""
|
|
|
|
# Calculate total
|
|
local total_size=0
|
|
while IFS='|' read -r size path; do
|
|
((total_size += size))
|
|
done < "$temp_dirs"
|
|
[[ $total_size -eq 0 ]] && total_size=1
|
|
|
|
# Show top 5 as blocks
|
|
local count=0
|
|
local chart_width=50
|
|
while IFS='|' read -r size path; do
|
|
if [[ $count -ge 5 ]]; then
|
|
break
|
|
fi
|
|
|
|
local percentage=$((size * 100 / total_size))
|
|
local bar_width=$((percentage * chart_width / 100))
|
|
[[ $bar_width -lt 1 ]] && bar_width=1
|
|
|
|
local dirname=$(basename "$path")
|
|
local human_size=$(bytes_to_human "$size")
|
|
|
|
# Build visual bar
|
|
local bar=""
|
|
if [[ $bar_width -gt 0 ]]; then
|
|
bar=$(printf "%${bar_width}s" "" | tr ' ' '█')
|
|
fi
|
|
|
|
printf " ${BLUE}%-15s${NC} %3d%% %s %s\n" \
|
|
"${dirname:0:15}" "$percentage" "$bar" "$human_size"
|
|
|
|
((count++))
|
|
done < "$temp_dirs"
|
|
echo ""
|
|
}
|
|
|
|
# Display recent large files (added in last 30 days)
|
|
display_recent_large_files() {
|
|
log_header "🆕 Recent Large Files (Last 30 Days)"
|
|
echo ""
|
|
|
|
if ! command -v mdfind &>/dev/null; then
|
|
echo " ${YELLOW}Note: mdfind not available${NC}"
|
|
echo ""
|
|
return
|
|
fi
|
|
|
|
local temp_recent="$TEMP_PREFIX.recent"
|
|
|
|
# Find files created in last 30 days, larger than 100MB
|
|
mdfind -onlyin "$CURRENT_PATH" \
|
|
"kMDItemFSSize > 100000000 && kMDItemContentCreationDate >= \$time.today(-30)" 2>/dev/null | \
|
|
while IFS= read -r file; do
|
|
if [[ -f "$file" ]]; then
|
|
local size=$(stat -f%z "$file" 2>/dev/null || echo "0")
|
|
local mtime=$(stat -f%m "$file" 2>/dev/null || echo "0")
|
|
echo "$size|$mtime|$file"
|
|
fi
|
|
done | sort -t'|' -k1 -rn | head -10 > "$temp_recent"
|
|
|
|
if [[ ! -s "$temp_recent" ]]; then
|
|
echo " ${GRAY}No large files created recently${NC}"
|
|
echo ""
|
|
return
|
|
fi
|
|
|
|
local count=0
|
|
while IFS='|' read -r size mtime path; do
|
|
local human_size=$(bytes_to_human "$size")
|
|
local filename=$(basename "$path")
|
|
local dirname=$(dirname "$path" | sed "s|^$HOME|~|")
|
|
local days_ago=$(( ($(date +%s) - mtime) / 86400 ))
|
|
|
|
printf " 📄 %s ${GRAY}(%s)${NC}\n" "$filename" "$human_size"
|
|
printf " ${GRAY}%s - %d days ago${NC}\n\n" "$dirname" "$days_ago"
|
|
|
|
((count++))
|
|
done < "$temp_recent"
|
|
}
|
|
|
|
# ============================================================================
|
|
# Interactive Navigation
|
|
# ============================================================================
|
|
|
|
# Get list of subdirectories
|
|
get_subdirectories() {
|
|
local target="$1"
|
|
local temp_file="$2"
|
|
|
|
find "$target" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | \
|
|
while IFS= read -r dir; do
|
|
local size=$(du -sk "$dir" 2>/dev/null | cut -f1)
|
|
echo "$((size * 1024))|$dir"
|
|
done | sort -t'|' -k1 -rn > "$temp_file"
|
|
}
|
|
|
|
# Display directory list for selection
|
|
display_directory_list() {
|
|
local temp_dirs="$TEMP_PREFIX.dirs"
|
|
local cursor_pos="${1:-0}"
|
|
|
|
if [[ ! -f "$temp_dirs" ]] || [[ ! -s "$temp_dirs" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
local idx=0
|
|
local max_size=0
|
|
local total_size=0
|
|
|
|
# Calculate totals
|
|
max_size=$(head -1 "$temp_dirs" | cut -d'|' -f1)
|
|
[[ -z "$max_size" ]] && max_size=1
|
|
while IFS='|' read -r size path; do
|
|
((total_size += size))
|
|
done < "$temp_dirs"
|
|
[[ $total_size -eq 0 ]] && total_size=1
|
|
|
|
# Display with cursor
|
|
while IFS='|' read -r size path; do
|
|
local human_size=$(bytes_to_human "$size")
|
|
local percentage=$(calc_percentage "$size" "$total_size")
|
|
local bar=$(generate_bar "$size" "$max_size" 20)
|
|
local display_path=$(echo "$path" | sed "s|^$HOME|~|")
|
|
local dirname=$(basename "$path")
|
|
|
|
# Highlight selected line
|
|
if [[ $idx -eq $cursor_pos ]]; then
|
|
printf " ${BLUE}▶${NC} %s [${GREEN}%s${NC}] %5s%% %s\n" \
|
|
"$bar" "$human_size" "$percentage" "$dirname"
|
|
else
|
|
printf " %s [${BLUE}%s${NC}] %5s%% %s\n" \
|
|
"$bar" "$human_size" "$percentage" "$dirname"
|
|
fi
|
|
|
|
((idx++))
|
|
if [[ $idx -ge 15 ]]; then
|
|
break
|
|
fi
|
|
done < "$temp_dirs"
|
|
|
|
return 0
|
|
}
|
|
|
|
# Get path at cursor position
|
|
get_path_at_cursor() {
|
|
local cursor_pos="$1"
|
|
local temp_dirs="$TEMP_PREFIX.dirs"
|
|
|
|
if [[ ! -f "$temp_dirs" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
local idx=0
|
|
while IFS='|' read -r size path; do
|
|
if [[ $idx -eq $cursor_pos ]]; then
|
|
echo "$path"
|
|
return 0
|
|
fi
|
|
((idx++))
|
|
done < "$temp_dirs"
|
|
|
|
return 1
|
|
}
|
|
|
|
# Count available directories
|
|
count_directories() {
|
|
local temp_dirs="$TEMP_PREFIX.dirs"
|
|
if [[ ! -f "$temp_dirs" ]]; then
|
|
echo "0"
|
|
return
|
|
fi
|
|
local count=$(wc -l < "$temp_dirs" | tr -d ' ')
|
|
[[ $count -gt 15 ]] && count=15
|
|
echo "$count"
|
|
}
|
|
|
|
# Display interactive menu
|
|
display_interactive_menu() {
|
|
clear_screen
|
|
|
|
log_header "🔍 Disk Space Analyzer"
|
|
echo ""
|
|
echo "📂 Current: ${BLUE}$(echo "$CURRENT_PATH" | sed "s|^$HOME|~|")${NC}"
|
|
echo ""
|
|
|
|
# Show navigation hints
|
|
echo "${GRAY}↑↓ Navigate | → Drill Down | ← Go Back | f Files | t Types | q Quit${NC}"
|
|
echo ""
|
|
|
|
# Display results based on view mode
|
|
case "$VIEW_MODE" in
|
|
"navigate")
|
|
log_header "📁 Select Directory"
|
|
echo ""
|
|
display_directory_list "$CURSOR_POS"
|
|
;;
|
|
"files")
|
|
display_large_files
|
|
;;
|
|
"types")
|
|
display_file_types
|
|
;;
|
|
*)
|
|
display_large_files
|
|
display_directories
|
|
display_hotspots
|
|
display_cleanup_suggestions
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Analyze file types
|
|
display_file_types() {
|
|
local temp_types="$TEMP_PREFIX.types"
|
|
|
|
log_header "📊 File Types Analysis"
|
|
echo ""
|
|
|
|
if ! command -v mdfind &>/dev/null; then
|
|
echo " ${YELLOW}Note: mdfind not available, limited analysis${NC}"
|
|
return
|
|
fi
|
|
|
|
# Analyze common file types
|
|
local -A type_map=(
|
|
["Videos"]="kMDItemContentType == 'public.movie' || kMDItemContentType == 'public.video'"
|
|
["Images"]="kMDItemContentType == 'public.image'"
|
|
["Archives"]="kMDItemContentType == 'public.archive' || kMDItemContentType == 'public.zip-archive'"
|
|
["Documents"]="kMDItemContentType == 'com.adobe.pdf' || kMDItemContentType == 'public.text'"
|
|
["Audio"]="kMDItemContentType == 'public.audio'"
|
|
)
|
|
|
|
for type_name in "${!type_map[@]}"; do
|
|
local query="${type_map[$type_name]}"
|
|
local files=$(mdfind -onlyin "$CURRENT_PATH" "$query" 2>/dev/null)
|
|
local count=$(echo "$files" | grep -c . || echo "0")
|
|
local total_size=0
|
|
|
|
if [[ $count -gt 0 ]]; then
|
|
while IFS= read -r file; do
|
|
if [[ -f "$file" ]]; then
|
|
local fsize=$(stat -f%z "$file" 2>/dev/null || echo "0")
|
|
((total_size += fsize))
|
|
fi
|
|
done <<< "$files"
|
|
|
|
if [[ $total_size -gt 0 ]]; then
|
|
local human_size=$(bytes_to_human "$total_size")
|
|
printf " 📦 %-12s %8s (%d files)\n" "$type_name:" "$human_size" "$count"
|
|
fi
|
|
fi
|
|
done
|
|
echo ""
|
|
}
|
|
|
|
# Read a single key press
|
|
read_single_key() {
|
|
local key=""
|
|
# Read single character without waiting for Enter
|
|
if read -rsn1 key 2>/dev/null; then
|
|
echo "$key"
|
|
else
|
|
echo "q"
|
|
fi
|
|
}
|
|
|
|
# Fast scan with progress display
|
|
scan_directory_contents_fast() {
|
|
local dir_path="$1"
|
|
local output_file="$2"
|
|
local max_items="${3:-16}"
|
|
local show_progress="${4:-true}"
|
|
|
|
local temp_all="$output_file.all"
|
|
|
|
# Count items first for progress bar
|
|
local total_dirs=$(find "$dir_path" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l | tr -d ' ')
|
|
|
|
local count=0
|
|
local last_update=0
|
|
|
|
# Get directories and files with sizes in parallel (much faster!)
|
|
local temp_dirs="$output_file.dirs"
|
|
local temp_files="$output_file.files"
|
|
|
|
# Parallel directory scanning using xargs (4 parallel jobs)
|
|
if [[ $total_dirs -gt 0 ]]; then
|
|
# Start parallel scan
|
|
find "$dir_path" -mindepth 1 -maxdepth 1 -type d -print0 2>/dev/null | \
|
|
xargs -0 -n 1 -P 4 sh -c '
|
|
size=$(du -sk "$1" 2>/dev/null | cut -f1 || echo 0)
|
|
echo "$((size * 1024))|dir|$1"
|
|
' _ > "$temp_dirs" &
|
|
local du_pid=$!
|
|
|
|
# Show progress while waiting
|
|
if [[ "$show_progress" == "true" ]] && [[ $total_dirs -gt 10 ]]; then
|
|
printf "\033[H\033[J" >&2
|
|
echo "" >&2
|
|
|
|
local spinner=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
|
|
local i=0
|
|
while kill -0 "$du_pid" 2>/dev/null; do
|
|
# Count how many results we have so far
|
|
local completed=$(wc -l < "$temp_dirs" 2>/dev/null | tr -d ' ')
|
|
[[ -z "$completed" ]] && completed=0
|
|
|
|
printf "\r ${BLUE}📊 ${spinner[$((i % 10))]} Scanning: %d/%d completed${NC}" "$completed" "$total_dirs" >&2
|
|
((i++))
|
|
sleep 0.15
|
|
done
|
|
printf "\r\033[K" >&2
|
|
fi
|
|
wait "$du_pid"
|
|
else
|
|
: > "$temp_dirs"
|
|
fi
|
|
|
|
# Files: get actual size (fast, no need for parallel)
|
|
find "$dir_path" -mindepth 1 -maxdepth 1 -type f 2>/dev/null | while IFS= read -r item; do
|
|
local size=$(stat -f%z "$item" 2>/dev/null || echo "0")
|
|
echo "$size|file|$item"
|
|
done > "$temp_files"
|
|
|
|
# Combine and sort
|
|
cat "$temp_dirs" "$temp_files" 2>/dev/null | sort -t'|' -k1 -rn | head -"$max_items" > "$output_file"
|
|
|
|
# Cleanup
|
|
rm -f "$temp_dirs" "$temp_files" 2>/dev/null
|
|
|
|
# Clear progress line if shown
|
|
if [[ "$show_progress" == "true" ]] && [[ $total_dirs -gt 10 ]]; then
|
|
printf "\r\033[K" >&2
|
|
fi
|
|
}
|
|
|
|
# Calculate directory sizes and update (now only used for deep refresh)
|
|
calculate_dir_sizes() {
|
|
local items_file="$1"
|
|
local max_items="${2:-15}" # Only recalculate first 15 by default
|
|
local temp_file="${items_file}.calc"
|
|
|
|
# Since we now scan with actual sizes, this function is mainly for refresh
|
|
# Just re-sort the existing data
|
|
sort -t'|' -k1 -rn "$items_file" > "$temp_file"
|
|
|
|
# Only update if source file still exists (might have been deleted if user quit)
|
|
if [[ -f "$items_file" ]]; then
|
|
mv "$temp_file" "$items_file" 2>/dev/null || true
|
|
else
|
|
rm -f "$temp_file" 2>/dev/null || true
|
|
fi
|
|
}
|
|
|
|
# Combine initial scan results (large files + directories) into one list
|
|
combine_initial_scan_results() {
|
|
local output_file="$1"
|
|
local temp_large="$TEMP_PREFIX.large"
|
|
local temp_dirs="$TEMP_PREFIX.dirs"
|
|
|
|
> "$output_file"
|
|
|
|
# Add directories
|
|
if [[ -f "$temp_dirs" ]]; then
|
|
while IFS='|' read -r size path; do
|
|
echo "$size|dir|$path"
|
|
done < "$temp_dirs" >> "$output_file"
|
|
fi
|
|
|
|
# Add large files (only files in current directory, not subdirectories)
|
|
if [[ -f "$temp_large" ]]; then
|
|
while IFS='|' read -r size path; do
|
|
# Only include if parent directory is the current scan path
|
|
local parent=$(dirname "$path")
|
|
if [[ "$parent" == "$CURRENT_PATH" ]]; then
|
|
echo "$size|file|$path"
|
|
fi
|
|
done < "$temp_large" >> "$output_file"
|
|
fi
|
|
|
|
# Sort by size
|
|
sort -t'|' -k1 -rn "$output_file" -o "$output_file"
|
|
}
|
|
|
|
# Show all volumes overview and let user select
|
|
show_volumes_overview() {
|
|
local temp_volumes="$TEMP_PREFIX.volumes"
|
|
|
|
# Collect all mounted volumes
|
|
{
|
|
# Root volume
|
|
local root_size=$(df -k / 2>/dev/null | tail -1 | awk '{print $3}')
|
|
echo "$((root_size * 1024))|/|Macintosh HD (Root)"
|
|
|
|
# External volumes
|
|
if [[ -d "/Volumes" ]]; then
|
|
find /Volumes -mindepth 1 -maxdepth 1 -type d 2>/dev/null | while IFS= read -r vol; do
|
|
local vol_size=$(df -k "$vol" 2>/dev/null | tail -1 | awk '{print $3}')
|
|
local vol_name=$(basename "$vol")
|
|
echo "$((vol_size * 1024))|$vol|$vol_name"
|
|
done
|
|
fi
|
|
|
|
# Common user directories
|
|
for dir in "$HOME" "$HOME/Downloads" "$HOME/Documents" "$HOME/Library"; do
|
|
if [[ -d "$dir" ]]; then
|
|
local dir_size=$(du -sk "$dir" 2>/dev/null | cut -f1)
|
|
local dir_name=$(echo "$dir" | sed "s|^$HOME|~|")
|
|
echo "$((dir_size * 1024))|$dir|$dir_name"
|
|
fi
|
|
done
|
|
} | sort -t'|' -k1 -rn > "$temp_volumes"
|
|
|
|
# Setup alternate screen
|
|
tput smcup 2>/dev/null || true
|
|
printf "\033[?25l" # Hide cursor
|
|
|
|
cleanup_volumes() {
|
|
printf "\033[?25h" # Show cursor
|
|
tput rmcup 2>/dev/null || true
|
|
}
|
|
trap cleanup_volumes EXIT INT TERM
|
|
|
|
local cursor=0
|
|
local total_items=$(wc -l < "$temp_volumes" | tr -d ' ')
|
|
|
|
while true; do
|
|
# Build output buffer to reduce flicker
|
|
local output=""
|
|
output+="\033[H\033[J"
|
|
output+=$'\n'
|
|
output+="\033[0;35m▶ 💾 Disk Volumes & Locations\033[0m"$'\n'
|
|
output+=$'\n'
|
|
output+=" ${GRAY}Select a location to explore. ↑/↓: Navigate | → / Enter: Open | ← / q: Quit${NC}"$'\n'
|
|
output+=$'\n'
|
|
output+=" TYPE SIZE LOCATION"$'\n'
|
|
output+=" ────────────────────────────────────────────────────────────────────────────────"$'\n'
|
|
|
|
local idx=0
|
|
while IFS='|' read -r size path display_name; do
|
|
local human_size=$(bytes_to_human "$size")
|
|
|
|
# Determine icon
|
|
local icon="💾"
|
|
local color="${NC}"
|
|
if [[ "$path" == "/" ]]; then
|
|
icon="💿"
|
|
color="${BLUE}"
|
|
elif [[ "$path" == /Volumes/* ]]; then
|
|
icon="🔌"
|
|
color="${YELLOW}"
|
|
elif [[ "$path" == "$HOME" ]]; then
|
|
icon="🏠"
|
|
color="${GREEN}"
|
|
elif [[ "$path" == *"/Library" ]]; then
|
|
icon="📚"
|
|
color="${GRAY}"
|
|
else
|
|
icon="📁"
|
|
fi
|
|
|
|
# Build line
|
|
local line=""
|
|
if [[ $idx -eq $cursor ]]; then
|
|
line=$(printf " ${GREEN}▶${NC} ${color}%-4s %-10s %s${NC}" "$icon" "$human_size" "$display_name")
|
|
else
|
|
line=$(printf " ${color}%-4s %-10s %s${NC}" "$icon" "$human_size" "$display_name")
|
|
fi
|
|
output+="$line"$'\n'
|
|
|
|
((idx++))
|
|
done < "$temp_volumes"
|
|
|
|
output+=$'\n'
|
|
|
|
# Output everything at once
|
|
printf "%b" "$output" >&2
|
|
|
|
# Read key (suppress any escape sequences that might leak)
|
|
local key
|
|
key=$(read_key 2>/dev/null || echo "OTHER")
|
|
|
|
case "$key" in
|
|
"UP")
|
|
((cursor > 0)) && ((cursor--))
|
|
;;
|
|
"DOWN")
|
|
((cursor < total_items - 1)) && ((cursor++))
|
|
;;
|
|
"ENTER"|"RIGHT")
|
|
# Get selected path
|
|
local selected_path=""
|
|
idx=0
|
|
while IFS='|' read -r size path display_name; do
|
|
if [[ $idx -eq $cursor ]]; then
|
|
selected_path="$path"
|
|
break
|
|
fi
|
|
((idx++))
|
|
done < "$temp_volumes"
|
|
|
|
if [[ -n "$selected_path" ]] && [[ -d "$selected_path" ]]; then
|
|
cleanup_volumes
|
|
trap - EXIT INT TERM
|
|
interactive_drill_down "$selected_path" ""
|
|
return
|
|
fi
|
|
;;
|
|
"QUIT"|"q")
|
|
break
|
|
;;
|
|
esac
|
|
done
|
|
|
|
cleanup_volumes
|
|
trap - EXIT INT TERM
|
|
}
|
|
|
|
# Interactive drill-down mode
|
|
interactive_drill_down() {
|
|
local start_path="$1"
|
|
local initial_items="${2:-}" # Pre-scanned items for first level
|
|
local current_path="$start_path"
|
|
local path_stack=()
|
|
local cursor=0
|
|
local need_scan=true
|
|
local wait_for_calc=false # Don't wait on first load, let user press 'r'
|
|
local temp_items="$TEMP_PREFIX.items"
|
|
|
|
# Cache variables to avoid recalculation
|
|
local -a items=()
|
|
local has_calculating=false
|
|
local total_items=0
|
|
|
|
# Setup alternate screen and hide cursor
|
|
tput smcup 2>/dev/null || true # Enter alternate screen
|
|
printf "\033[?25l" # Hide cursor
|
|
|
|
# Cleanup on exit
|
|
cleanup_drill_down() {
|
|
printf "\033[?25h" # Show cursor
|
|
tput rmcup 2>/dev/null || true # Exit alternate screen
|
|
}
|
|
trap cleanup_drill_down EXIT INT TERM
|
|
|
|
while true; do
|
|
# Drain any burst input (e.g. trackpad scroll converted to many arrow keys)
|
|
type drain_pending_input >/dev/null 2>&1 && drain_pending_input
|
|
# Only scan if needed (directory changed or refresh requested)
|
|
if [[ "$need_scan" == "true" ]]; then
|
|
# Clear screen for scanning
|
|
printf "\033[H\033[J" >&2
|
|
|
|
# Fast scan: list items immediately (top 16 only)
|
|
scan_directory_contents_fast "$current_path" "$temp_items" 16
|
|
|
|
# Load items into array
|
|
items=()
|
|
if [[ -f "$temp_items" ]] && [[ -s "$temp_items" ]]; then
|
|
while IFS='|' read -r size type path; do
|
|
items+=("$size|$type|$path")
|
|
done < "$temp_items"
|
|
fi
|
|
total_items=${#items[@]}
|
|
|
|
# No more calculating state
|
|
has_calculating=false
|
|
need_scan=false
|
|
wait_for_calc=false
|
|
|
|
# Check if empty
|
|
if [[ $total_items -eq 0 ]]; then
|
|
# Empty directory - go back
|
|
printf "\033[H\033[J" >&2
|
|
echo "" >&2
|
|
echo " ${YELLOW}Empty directory${NC}" >&2
|
|
echo "" >&2
|
|
echo " ${GRAY}Press any key to go back...${NC}" >&2
|
|
read_key >/dev/null 2>&1
|
|
if [[ ${#path_stack[@]} -gt 0 ]]; then
|
|
current_path="${path_stack[-1]}"
|
|
unset 'path_stack[-1]'
|
|
cursor=0
|
|
need_scan=true
|
|
continue
|
|
else
|
|
break
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# Build output buffer once for smooth rendering
|
|
local output=""
|
|
output+="\033[H\033[J" # Clear screen
|
|
output+=$'\n'
|
|
output+="\033[0;35m▶ 📊 Disk Space Explorer\033[0m"$'\n'
|
|
output+=$'\n'
|
|
output+=" ${BLUE}Current:${NC} $(echo "$current_path" | sed "s|^$HOME|~|")"$'\n'
|
|
output+=" ${GRAY}↑/↓: Navigate | → / Enter: Open folder | ← / Backspace / q: Back | q: Quit${NC}"$'\n'
|
|
output+=$'\n'
|
|
output+=" ${YELLOW}Items (sorted by size):${NC}"$'\n'
|
|
output+=$'\n'
|
|
output+=" TYPE SIZE NAME"$'\n'
|
|
output+=" ────────────────────────────────────────────────────────────────────────────────"$'\n'
|
|
|
|
local max_show=16
|
|
local idx=0
|
|
for item_info in "${items[@]}"; do
|
|
[[ $idx -ge $max_show ]] && break
|
|
|
|
local size="${item_info%%|*}"
|
|
local rest="${item_info#*|}"
|
|
local type="${rest%%|*}"
|
|
local path="${rest#*|}"
|
|
local name=$(basename "$path")
|
|
|
|
local human_size
|
|
if [[ "$size" -eq 0 ]]; then
|
|
human_size="0B"
|
|
else
|
|
human_size=$(bytes_to_human "$size")
|
|
fi
|
|
|
|
# Get icon and color
|
|
local icon="" color="${NC}"
|
|
if [[ "$type" == "dir" ]]; then
|
|
icon="📁" color="${BLUE}"
|
|
if [[ $size -gt 10737418240 ]]; then color="${RED}"
|
|
elif [[ $size -gt 1073741824 ]]; then color="${YELLOW}"
|
|
fi
|
|
else
|
|
local ext="${name##*.}"
|
|
case "$ext" in
|
|
dmg|iso|pkg) icon="📦" ; color="${RED}" ;;
|
|
mov|mp4|avi|mkv|webm) icon="🎬" ; color="${YELLOW}" ;;
|
|
zip|tar|gz|rar|7z) icon="🗜️" ; color="${YELLOW}" ;;
|
|
pdf) icon="📄" ;;
|
|
jpg|jpeg|png|gif|heic) icon="🖼️" ;;
|
|
key|ppt|pptx) icon="📊" ;;
|
|
log) icon="📝" ; color="${GRAY}" ;;
|
|
*) icon="📄" ;;
|
|
esac
|
|
fi
|
|
|
|
# Truncate name
|
|
if [[ ${#name} -gt 55 ]]; then name="${name:0:52}..."; fi
|
|
|
|
# Build line
|
|
local line
|
|
if [[ $idx -eq $cursor ]]; then
|
|
line=$(printf " ${GREEN}▶${NC} ${color}%-4s %-10s %s${NC}" "$icon" "$human_size" "$name")
|
|
else
|
|
line=$(printf " ${color}%-4s %-10s %s${NC}" "$icon" "$human_size" "$name")
|
|
fi
|
|
output+="$line"$'\n'
|
|
|
|
((idx++))
|
|
done
|
|
|
|
output+=$'\n'
|
|
|
|
# Output everything at once (single write = no flicker)
|
|
printf "%b" "$output" >&2
|
|
|
|
# Read key (suppress any escape sequences that might leak)
|
|
local key
|
|
key=$(read_key 2>/dev/null || echo "OTHER")
|
|
|
|
case "$key" in
|
|
"UP")
|
|
((cursor > 0)) && ((cursor--))
|
|
;;
|
|
"DOWN")
|
|
local max_cursor=$(( total_items < max_show ? total_items - 1 : max_show - 1 ))
|
|
((cursor < max_cursor)) && ((cursor++))
|
|
;;
|
|
"ENTER"|"RIGHT")
|
|
# Enter selected item (only if it's a directory)
|
|
if [[ $cursor -lt ${#items[@]} ]]; then
|
|
local selected="${items[$cursor]}"
|
|
local size="${selected%%|*}"
|
|
local rest="${selected#*|}"
|
|
local type="${rest%%|*}"
|
|
local selected_path="${rest#*|}"
|
|
|
|
if [[ "$type" == "dir" ]]; then
|
|
path_stack+=("$current_path")
|
|
current_path="$selected_path"
|
|
cursor=0
|
|
need_scan=true
|
|
fi
|
|
fi
|
|
;;
|
|
"BACKSPACE"|"LEFT")
|
|
# Go back
|
|
if [[ ${#path_stack[@]} -gt 0 ]]; then
|
|
current_path="${path_stack[-1]}"
|
|
unset 'path_stack[-1]'
|
|
cursor=0
|
|
need_scan=true
|
|
else
|
|
break
|
|
fi
|
|
;;
|
|
"QUIT"|"q")
|
|
break
|
|
;;
|
|
"r"|"R"|"SPACE")
|
|
# Refresh: re-scan current directory
|
|
need_scan=true
|
|
wait_for_calc=true
|
|
;;
|
|
esac
|
|
done
|
|
|
|
# Cleanup is handled by trap
|
|
}
|
|
|
|
# Main interactive loop
|
|
interactive_mode() {
|
|
CURSOR_POS=0
|
|
VIEW_MODE="overview"
|
|
|
|
while true; do
|
|
type drain_pending_input >/dev/null 2>&1 && drain_pending_input
|
|
display_interactive_menu
|
|
|
|
local key=$(read_key)
|
|
case "$key" in
|
|
"QUIT")
|
|
break
|
|
;;
|
|
"UP")
|
|
if [[ "$VIEW_MODE" == "navigate" ]]; then
|
|
((CURSOR_POS > 0)) && ((CURSOR_POS--))
|
|
fi
|
|
;;
|
|
"DOWN")
|
|
if [[ "$VIEW_MODE" == "navigate" ]]; then
|
|
local max_count=$(count_directories)
|
|
((CURSOR_POS < max_count - 1)) && ((CURSOR_POS++))
|
|
fi
|
|
;;
|
|
"RIGHT")
|
|
if [[ "$VIEW_MODE" == "navigate" ]]; then
|
|
# Enter selected directory
|
|
local selected_path=$(get_path_at_cursor "$CURSOR_POS")
|
|
if [[ -n "$selected_path" ]] && [[ -d "$selected_path" ]]; then
|
|
CURRENT_PATH="$selected_path"
|
|
CURSOR_POS=0
|
|
perform_scan "$CURRENT_PATH"
|
|
fi
|
|
else
|
|
# Enter navigation mode
|
|
VIEW_MODE="navigate"
|
|
CURSOR_POS=0
|
|
fi
|
|
;;
|
|
"LEFT")
|
|
if [[ "$VIEW_MODE" == "navigate" ]]; then
|
|
# Go back to parent
|
|
if [[ "$CURRENT_PATH" != "$HOME" ]] && [[ "$CURRENT_PATH" != "/" ]]; then
|
|
CURRENT_PATH="$(dirname "$CURRENT_PATH")"
|
|
CURSOR_POS=0
|
|
perform_scan "$CURRENT_PATH"
|
|
fi
|
|
else
|
|
VIEW_MODE="overview"
|
|
fi
|
|
;;
|
|
"f"|"F")
|
|
VIEW_MODE="files"
|
|
;;
|
|
"t"|"T")
|
|
VIEW_MODE="types"
|
|
;;
|
|
"ENTER")
|
|
if [[ "$VIEW_MODE" == "navigate" ]]; then
|
|
# Same as RIGHT
|
|
local selected_path=$(get_path_at_cursor "$CURSOR_POS")
|
|
if [[ -n "$selected_path" ]] && [[ -d "$selected_path" ]]; then
|
|
CURRENT_PATH="$selected_path"
|
|
CURSOR_POS=0
|
|
perform_scan "$CURRENT_PATH"
|
|
fi
|
|
else
|
|
break
|
|
fi
|
|
;;
|
|
*)
|
|
# Any other key in overview mode exits
|
|
if [[ "$VIEW_MODE" == "overview" ]]; then
|
|
break
|
|
fi
|
|
;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# ============================================================================
|
|
# Main Entry Point
|
|
# ============================================================================
|
|
|
|
# Export results to CSV
|
|
export_to_csv() {
|
|
local output_file="$1"
|
|
local temp_dirs="$TEMP_PREFIX.dirs"
|
|
|
|
if [[ ! -f "$temp_dirs" ]]; then
|
|
log_error "No scan data available to export"
|
|
return 1
|
|
fi
|
|
|
|
{
|
|
echo "Size (Bytes),Size (Human),Path"
|
|
while IFS='|' read -r size path; do
|
|
local human=$(bytes_to_human "$size")
|
|
echo "$size,\"$human\",\"$path\""
|
|
done < "$temp_dirs"
|
|
} > "$output_file"
|
|
|
|
log_success "Exported to $output_file"
|
|
}
|
|
|
|
# Export results to JSON
|
|
export_to_json() {
|
|
local output_file="$1"
|
|
local temp_dirs="$TEMP_PREFIX.dirs"
|
|
local temp_large="$TEMP_PREFIX.large"
|
|
|
|
if [[ ! -f "$temp_dirs" ]]; then
|
|
log_error "No scan data available to export"
|
|
return 1
|
|
fi
|
|
|
|
{
|
|
echo "{"
|
|
echo " \"scan_date\": \"$(date -u +"%Y-%m-%dT%H:%M:%SZ")\","
|
|
echo " \"target_path\": \"$CURRENT_PATH\","
|
|
echo " \"directories\": ["
|
|
|
|
local first=true
|
|
while IFS='|' read -r size path; do
|
|
[[ "$first" == "false" ]] && echo ","
|
|
first=false
|
|
local human=$(bytes_to_human "$size")
|
|
printf ' {"size": %d, "size_human": "%s", "path": "%s"}' "$size" "$human" "$path"
|
|
done < "$temp_dirs"
|
|
|
|
echo ""
|
|
echo " ],"
|
|
echo " \"large_files\": ["
|
|
|
|
if [[ -f "$temp_large" ]]; then
|
|
first=true
|
|
while IFS='|' read -r size path; do
|
|
[[ "$first" == "false" ]] && echo ","
|
|
first=false
|
|
local human=$(bytes_to_human "$size")
|
|
printf ' {"size": %d, "size_human": "%s", "path": "%s"}' "$size" "$human" "$path"
|
|
done < "$temp_large"
|
|
echo ""
|
|
fi
|
|
|
|
echo " ]"
|
|
echo "}"
|
|
} > "$output_file"
|
|
|
|
log_success "Exported to $output_file"
|
|
}
|
|
|
|
main() {
|
|
local target_path="$HOME"
|
|
local interactive=false
|
|
local export_format=""
|
|
local export_file=""
|
|
local show_volumes=false
|
|
|
|
# Parse arguments
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-i|--interactive)
|
|
interactive=true
|
|
shift
|
|
;;
|
|
-a|--all)
|
|
show_volumes=true
|
|
shift
|
|
;;
|
|
-e|--export)
|
|
export_format="$2"
|
|
export_file="${3:-disk_analysis_$(date +%Y%m%d_%H%M%S).$export_format}"
|
|
shift 2
|
|
[[ $# -gt 0 ]] && shift
|
|
;;
|
|
-h|--help)
|
|
echo "Usage: mole analyze [options] [path]"
|
|
echo ""
|
|
echo "Interactive disk space explorer - navigate like a file manager, sorted by size."
|
|
echo ""
|
|
echo "Options:"
|
|
echo " -a, --all Start with all volumes view (/, /Volumes/*)"
|
|
echo " -i, --interactive Use old interactive mode (legacy)"
|
|
echo " -h, --help Show this help"
|
|
echo ""
|
|
echo "Examples:"
|
|
echo " mole analyze # Explore home directory"
|
|
echo " mole analyze --all # Start with all disk volumes"
|
|
echo " mole analyze ~/Downloads # Explore Downloads"
|
|
echo " mole analyze ~/Library # Check system caches"
|
|
echo ""
|
|
echo "Features:"
|
|
echo " • Files and folders mixed together, sorted by size (largest first)"
|
|
echo " • Shows top 16 items per directory (largest items only)"
|
|
echo " • Use ↑/↓ to navigate, Enter to open folders, Backspace to go back"
|
|
echo " • Files (📦🎬📄) shown but can't be opened, only folders (📁) can"
|
|
echo " • Color coding: Red folders >10GB, Yellow >1GB, installers/videos highlighted"
|
|
echo " • Press q to quit at any time"
|
|
exit 0
|
|
;;
|
|
-*)
|
|
log_error "Unknown option: $1"
|
|
exit 1
|
|
;;
|
|
*)
|
|
target_path="$1"
|
|
shift
|
|
;;
|
|
esac
|
|
done
|
|
|
|
# Validate path
|
|
if [[ ! -d "$target_path" ]]; then
|
|
log_error "Invalid path: $target_path"
|
|
exit 1
|
|
fi
|
|
|
|
CURRENT_PATH="$target_path"
|
|
|
|
# Create cache directory
|
|
mkdir -p "$CACHE_DIR" 2>/dev/null || true
|
|
|
|
# Handle export if requested (requires scan)
|
|
if [[ -n "$export_format" ]]; then
|
|
# Check for mdfind
|
|
if ! command -v mdfind &>/dev/null; then
|
|
log_warning "mdfind not available, falling back to slower scan method"
|
|
fi
|
|
|
|
perform_scan "$target_path"
|
|
|
|
case "$export_format" in
|
|
csv)
|
|
export_to_csv "$export_file"
|
|
exit 0
|
|
;;
|
|
json)
|
|
export_to_json "$export_file"
|
|
exit 0
|
|
;;
|
|
*)
|
|
log_error "Unknown export format: $export_format (use csv or json)"
|
|
exit 1
|
|
;;
|
|
esac
|
|
fi
|
|
|
|
if [[ "$interactive" == "true" ]]; then
|
|
# Old interactive mode (keep for compatibility)
|
|
if ! command -v mdfind &>/dev/null; then
|
|
log_warning "mdfind not available, falling back to slower scan method"
|
|
fi
|
|
perform_scan "$target_path"
|
|
interactive_mode
|
|
else
|
|
# Show volumes view if requested
|
|
if [[ "$show_volumes" == "true" ]]; then
|
|
show_volumes_overview
|
|
else
|
|
# New default: directly enter interactive drill-down mode (NO initial scan!)
|
|
interactive_drill_down "$target_path" ""
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# Run if executed directly
|
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
main "$@"
|
|
fi |