1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-04 15:39:42 +00:00

Complete automated testing

This commit is contained in:
Tw93
2025-10-12 15:43:45 +08:00
parent f9a93f6052
commit 3c56fe0633
13 changed files with 484 additions and 198 deletions

45
.github/workflows/shell-format.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
name: Shell Format & Lint
on:
push:
branches: [main]
paths:
- '**/*.sh'
- mole
pull_request:
paths:
- '**/*.sh'
- mole
jobs:
format-lint:
runs-on: macos-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install shfmt and shellcheck
run: brew install shfmt shellcheck
- name: Run shfmt in diff mode
run: |
set -euo pipefail
mapfile -d '' files < <(find . -type f \( -name '*.sh' -o -name 'mole' \) \
! -path './.git/*' ! -path './tests/tmp-*' -print0)
if (( ${#files[@]} == 0 )); then
echo "No shell files found; skipping shfmt"
exit 0
fi
shfmt -i 4 -ci -sr -d "${files[@]}"
- name: Run shellcheck
run: |
set -euo pipefail
mapfile -d '' files < <(find . -type f \( -name '*.sh' -o -name 'mole' \) \
! -path './.git/*' ! -path './tests/tmp-*' -print0)
if (( ${#files[@]} == 0 )); then
echo "No shell files found; skipping shellcheck"
exit 0
fi
shellcheck --rcfile .shellcheckrc "${files[@]}"

10
.shellcheckrc Normal file
View File

@@ -0,0 +1,10 @@
# Mole project shellcheck configuration
#
# Keep the lint strict by default. Add rules to disable only when we have a
# clear justification.
#
# Examples:
# disable=SC2034 # unused variables (if intentionally unused)
# disable=SC1091 # sourcing files not present in repo (optional)
# Currently no global disables are required.

View File

@@ -141,10 +141,47 @@ Total: 156.8GB
└─ 📁 Desktop 12.7GB └─ 📁 Desktop 12.7GB
``` ```
## Development
### Setup
Install development tools:
```bash
brew install shfmt shellcheck bats-core
```
### Code Quality
Format and lint shell scripts:
```bash
# Format all scripts
./scripts/format.sh
# Check without modifying
./scripts/format.sh --check
# Install git hooks for auto-formatting
./scripts/install-hooks.sh
```
See [scripts/README.md](scripts/README.md) for detailed development workflow.
### Testing
Run automated tests:
```bash
./tests/run.sh
```
GitHub Actions automatically runs tests and formatting checks on PRs.
## Support ## Support
- If Mole reclaimed storage for you, consider starring the repo or sharing it with friends needing a cleaner Mac. - If Mole reclaimed storage for you, consider starring the repo or sharing it with friends needing a cleaner Mac.
- Have ideas or fixes? Open an issue or PR and help shape Moles roadmap together with the community. - Have ideas or fixes? Open an issue or PR and help shape Mole's roadmap together with the community.
- Love cats? Treat Tangyuan and Cola to canned food via <a href="https://miaoyan.app/cats.html?name=Mole" target="_blank">this link</a> and keep the mascots purring. - Love cats? Treat Tangyuan and Cola to canned food via <a href="https://miaoyan.app/cats.html?name=Mole" target="_blank">this link</a> and keep the mascots purring.
## License ## License

View File

@@ -74,7 +74,8 @@ scan_large_files() {
local file="" local file=""
while IFS= read -r file; do while IFS= read -r file; do
if [[ -f "$file" ]]; then if [[ -f "$file" ]]; then
local size=$(stat -f%z "$file" 2>/dev/null || echo "0") local size
size=$(stat -f%z "$file" 2>/dev/null || echo "0")
echo "$size|$file" echo "$size|$file"
fi fi
done < <(mdfind -onlyin "$target_path" "kMDItemFSSize > $MIN_LARGE_FILE_SIZE" 2>/dev/null) | \ done < <(mdfind -onlyin "$target_path" "kMDItemFSSize > $MIN_LARGE_FILE_SIZE" 2>/dev/null) | \
@@ -93,7 +94,8 @@ scan_medium_files() {
local file="" local file=""
while IFS= read -r file; do while IFS= read -r file; do
if [[ -f "$file" ]]; then if [[ -f "$file" ]]; then
local size=$(stat -f%z "$file" 2>/dev/null || echo "0") local size
size=$(stat -f%z "$file" 2>/dev/null || echo "0")
echo "$size|$file" echo "$size|$file"
fi fi
done < <(mdfind -onlyin "$target_path" \ done < <(mdfind -onlyin "$target_path" \
@@ -158,7 +160,8 @@ aggregate_by_directory() {
# Get cache file path for a directory # Get cache file path for a directory
get_cache_file() { get_cache_file() {
local target_path="$1" local target_path="$1"
local path_hash=$(echo "$target_path" | md5 2>/dev/null || echo "$target_path" | shasum | cut -d' ' -f1) 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" echo "$CACHE_DIR/scan_${path_hash}.cache"
} }
@@ -171,7 +174,8 @@ is_cache_valid() {
return 1 return 1
fi fi
local cache_age=$(($(date +%s) - $(stat -f%m "$cache_file" 2>/dev/null || echo 0))) local cache_age
cache_age=$(($(date +%s) - $(stat -f%m "$cache_file" 2>/dev/null || echo 0)))
if [[ $cache_age -lt $max_age ]]; then if [[ $cache_age -lt $max_age ]]; then
return 0 return 0
fi fi
@@ -236,7 +240,8 @@ perform_scan() {
local force_rescan="${2:-false}" local force_rescan="${2:-false}"
# Check cache first # Check cache first
local cache_file=$(get_cache_file "$target_path") local cache_file
cache_file=$(get_cache_file "$target_path")
if [[ "$force_rescan" != "true" ]] && is_cache_valid "$cache_file" 3600; then if [[ "$force_rescan" != "true" ]] && is_cache_valid "$cache_file" 3600; then
log_info "Loading cached results for $target_path..." log_info "Loading cached results for $target_path..."
load_from_cache "$cache_file" load_from_cache "$cache_file"
@@ -328,8 +333,10 @@ generate_bar() {
return return
fi fi
local filled=$((current * width / max)) local filled
local empty=$((width - filled)) filled=$((current * width / max))
local empty
empty=$((width - filled))
# Ensure non-negative # Ensure non-negative
[[ $filled -lt 0 ]] && filled=0 [[ $filled -lt 0 ]] && filled=0
@@ -372,7 +379,8 @@ display_large_files_compact() {
local count=0 local count=0
local total_size=0 local total_size=0
local total_count=$(wc -l < "$temp_large" | tr -d ' ') local total_count
total_count=$(wc -l < "$temp_large" | tr -d ' ')
# Calculate total size # Calculate total size
while IFS='|' read -r size path; do while IFS='|' read -r size path; do
@@ -385,11 +393,15 @@ display_large_files_compact() {
break break
fi fi
local human_size=$(bytes_to_human "$size") local human_size
local filename=$(basename "$path") human_size=$(bytes_to_human "$size")
local dirname=$(basename "$(dirname "$path")") local filename
filename=$(basename "$path")
local dirname
dirname=$(basename "$(dirname "$path")")
local info=$(get_file_info "$path") local info
info=$(get_file_info "$path")
local badge="${info%|*}" local badge="${info%|*}"
printf " ${GREEN}%-8s${NC} %s %-40s ${GRAY}%s${NC}\n" \ printf " ${GREEN}%-8s${NC} %s %-40s ${GRAY}%s${NC}\n" \
"$human_size" "$badge" "${filename:0:40}" "$dirname" "$human_size" "$badge" "${filename:0:40}" "$dirname"
@@ -398,7 +410,8 @@ display_large_files_compact() {
done < "$temp_large" done < "$temp_large"
echo "" echo ""
local total_human=$(bytes_to_human "$total_size") local total_human
total_human=$(bytes_to_human "$total_size")
echo " ${GRAY}Found $total_count large files (>1GB), totaling $total_human${NC}" echo " ${GRAY}Found $total_count large files (>1GB), totaling $total_human${NC}"
echo "" echo ""
} }
@@ -430,13 +443,19 @@ display_large_files() {
break break
fi fi
local human_size=$(bytes_to_human "$size") local human_size
local percentage=$(calc_percentage "$size" "$max_size") human_size=$(bytes_to_human "$size")
local bar=$(generate_bar "$size" "$max_size" 20) local percentage
local filename=$(basename "$path") percentage=$(calc_percentage "$size" "$max_size")
local dirname=$(dirname "$path" | sed "s|^$HOME|~|") local bar
bar=$(generate_bar "$size" "$max_size" 20)
local filename
filename=$(basename "$path")
local dirname
dirname=$(dirname "$path" | sed "s|^$HOME|~|")
local info=$(get_file_info "$path") local info
info=$(get_file_info "$path")
local badge="${info%|*}" local badge="${info%|*}"
printf " %s [${GREEN}%s${NC}] %7s\n" "$bar" "$human_size" "" printf " %s [${GREEN}%s${NC}] %7s\n" "$bar" "$human_size" ""
printf " %s %s\n" "$badge" "$filename" printf " %s %s\n" "$badge" "$filename"
@@ -446,7 +465,8 @@ display_large_files() {
done < "$temp_large" done < "$temp_large"
# Show total count # Show total count
local total_count=$(wc -l < "$temp_large" | tr -d ' ') local total_count
total_count=$(wc -l < "$temp_large" | tr -d ' ')
if [[ $total_count -gt 10 ]]; then if [[ $total_count -gt 10 ]]; then
echo " ${GRAY}... and $((total_count - 10)) more files${NC}" echo " ${GRAY}... and $((total_count - 10)) more files${NC}"
echo "" echo ""
@@ -479,17 +499,22 @@ display_directories_compact() {
break break
fi fi
local human_size=$(bytes_to_human "$size") local human_size
local percentage=$(calc_percentage "$size" "$total_size") human_size=$(bytes_to_human "$size")
local dirname=$(basename "$path") local percentage
percentage=$(calc_percentage "$size" "$total_size")
local dirname
dirname=$(basename "$path")
# Simple bar (10 chars) # Simple bar (10 chars)
local bar_width=10 local bar_width=10
local percentage_int=${percentage%.*} # Remove decimal part local percentage_int=${percentage%.*} # Remove decimal part
local filled=$((percentage_int * bar_width / 100)) local filled
filled=$((percentage_int * bar_width / 100))
[[ $filled -gt $bar_width ]] && filled=$bar_width [[ $filled -gt $bar_width ]] && filled=$bar_width
[[ $filled -lt 0 ]] && filled=0 [[ $filled -lt 0 ]] && filled=0
local empty=$((bar_width - filled)) local empty
empty=$((bar_width - filled))
[[ $empty -lt 0 ]] && empty=0 [[ $empty -lt 0 ]] && empty=0
local bar="" local bar=""
if [[ $filled -gt 0 ]]; then if [[ $filled -gt 0 ]]; then
@@ -538,11 +563,16 @@ display_directories() {
break break
fi fi
local human_size=$(bytes_to_human "$size") local human_size
local percentage=$(calc_percentage "$size" "$total_size") human_size=$(bytes_to_human "$size")
local bar=$(generate_bar "$size" "$max_size" 20) local percentage
local display_path=$(echo "$path" | sed "s|^$HOME|~|") percentage=$(calc_percentage "$size" "$total_size")
local dirname=$(basename "$path") 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 [${BLUE}%s${NC}] %5s%%\n" "$bar" "$human_size" "$percentage"
printf " %s %s\n\n" "$BADGE_DIR" "$display_path" printf " %s %s\n\n" "$BADGE_DIR" "$display_path"
@@ -568,8 +598,10 @@ display_hotspots() {
break break
fi fi
local human_size=$(bytes_to_human "$size") local human_size
local display_path=$(echo "$path" | sed "s|^$HOME|~|") human_size=$(bytes_to_human "$size")
local display_path
display_path=$(echo "$path" | sed "s|^$HOME|~|")
printf " %s\n" "$display_path" printf " %s\n" "$display_path"
printf " ${GREEN}%s${NC} in ${YELLOW}%d${NC} large files\n\n" \ printf " ${GREEN}%s${NC} in ${YELLOW}%d${NC} large files\n\n" \
@@ -589,9 +621,11 @@ display_cleanup_suggestions_compact() {
# Check common cache locations (only if analyzing Library/Caches or system paths) # Check common cache locations (only if analyzing Library/Caches or system paths)
if [[ "$CURRENT_PATH" == "$HOME/Library/Caches"* ]] || [[ "$CURRENT_PATH" == "$HOME/Library"* ]]; then if [[ "$CURRENT_PATH" == "$HOME/Library/Caches"* ]] || [[ "$CURRENT_PATH" == "$HOME/Library"* ]]; then
if [[ -d "$HOME/Library/Caches" ]]; then if [[ -d "$HOME/Library/Caches" ]]; then
local cache_size=$(du -sk "$HOME/Library/Caches" 2>/dev/null | cut -f1) local cache_size
cache_size=$(du -sk "$HOME/Library/Caches" 2>/dev/null | cut -f1)
if [[ $cache_size -gt 1048576 ]]; then # > 1GB if [[ $cache_size -gt 1048576 ]]; then # > 1GB
local human=$(bytes_to_human $((cache_size * 1024))) local human
human=$(bytes_to_human $((cache_size * 1024)))
top_suggestion="Clear app caches ($human)" top_suggestion="Clear app caches ($human)"
action_command="mole clean" action_command="mole clean"
((potential_space += cache_size * 1024)) ((potential_space += cache_size * 1024))
@@ -602,7 +636,8 @@ display_cleanup_suggestions_compact() {
# Check Downloads folder (only if analyzing Downloads) # Check Downloads folder (only if analyzing Downloads)
if [[ "$CURRENT_PATH" == "$HOME/Downloads"* ]]; then if [[ "$CURRENT_PATH" == "$HOME/Downloads"* ]]; then
local old_files=$(find "$CURRENT_PATH" -type f -mtime +90 2>/dev/null | wc -l | tr -d ' ') 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 if [[ $old_files -gt 0 ]]; then
[[ -z "$top_suggestion" ]] && top_suggestion="$old_files files older than 90 days found" [[ -z "$top_suggestion" ]] && top_suggestion="$old_files files older than 90 days found"
[[ -z "$action_command" ]] && action_command="manually review old files" [[ -z "$action_command" ]] && action_command="manually review old files"
@@ -618,7 +653,8 @@ display_cleanup_suggestions_compact() {
local dmg_size=$(mdfind -onlyin "$CURRENT_PATH" \ local dmg_size=$(mdfind -onlyin "$CURRENT_PATH" \
"kMDItemFSSize > 500000000 && kMDItemDisplayName == '*.dmg'" 2>/dev/null | \ "kMDItemFSSize > 500000000 && kMDItemDisplayName == '*.dmg'" 2>/dev/null | \
xargs stat -f%z 2>/dev/null | awk '{sum+=$1} END {print sum}') xargs stat -f%z 2>/dev/null | awk '{sum+=$1} END {print sum}')
local dmg_human=$(bytes_to_human "$dmg_size") 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 "$top_suggestion" ]] && top_suggestion="$dmg_count DMG files ($dmg_human) can be removed"
[[ -z "$action_command" ]] && action_command="manually delete DMG files" [[ -z "$action_command" ]] && action_command="manually delete DMG files"
((potential_space += dmg_size)) ((potential_space += dmg_size))
@@ -628,9 +664,11 @@ display_cleanup_suggestions_compact() {
# Check Xcode (only if in developer paths) # Check Xcode (only if in developer paths)
if [[ "$CURRENT_PATH" == "$HOME/Library/Developer"* ]] && [[ -d "$HOME/Library/Developer/Xcode/DerivedData" ]]; then 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) local xcode_size
xcode_size=$(du -sk "$HOME/Library/Developer/Xcode/DerivedData" 2>/dev/null | cut -f1)
if [[ $xcode_size -gt 10485760 ]]; then if [[ $xcode_size -gt 10485760 ]]; then
local xcode_human=$(bytes_to_human $((xcode_size * 1024))) local xcode_human
xcode_human=$(bytes_to_human $((xcode_size * 1024)))
[[ -z "$top_suggestion" ]] && top_suggestion="Xcode cache ($xcode_human) can be cleared" [[ -z "$top_suggestion" ]] && top_suggestion="Xcode cache ($xcode_human) can be cleared"
[[ -z "$action_command" ]] && action_command="mole clean" [[ -z "$action_command" ]] && action_command="mole clean"
((potential_space += xcode_size * 1024)) ((potential_space += xcode_size * 1024))
@@ -656,7 +694,8 @@ display_cleanup_suggestions_compact() {
echo " ${GRAY}... and $((suggestions_count - 1)) more insights${NC}" echo " ${GRAY}... and $((suggestions_count - 1)) more insights${NC}"
fi fi
if [[ $potential_space -gt 0 ]]; then if [[ $potential_space -gt 0 ]]; then
local space_human=$(bytes_to_human "$potential_space") local space_human
space_human=$(bytes_to_human "$potential_space")
echo " ${GREEN}Potential recovery: ~$space_human${NC}" echo " ${GREEN}Potential recovery: ~$space_human${NC}"
fi fi
echo "" echo ""
@@ -680,16 +719,19 @@ display_cleanup_suggestions() {
# Check common cache locations # Check common cache locations
if [[ -d "$HOME/Library/Caches" ]]; then if [[ -d "$HOME/Library/Caches" ]]; then
local cache_size=$(du -sk "$HOME/Library/Caches" 2>/dev/null | cut -f1) local cache_size
cache_size=$(du -sk "$HOME/Library/Caches" 2>/dev/null | cut -f1)
if [[ $cache_size -gt 1048576 ]]; then # > 1GB if [[ $cache_size -gt 1048576 ]]; then # > 1GB
local human=$(bytes_to_human $((cache_size * 1024))) local human
human=$(bytes_to_human $((cache_size * 1024)))
suggestions+=(" Clear application caches: $human") suggestions+=(" Clear application caches: $human")
fi fi
fi fi
# Check Downloads folder # Check Downloads folder
if [[ -d "$HOME/Downloads" ]]; then if [[ -d "$HOME/Downloads" ]]; then
local old_files=$(find "$HOME/Downloads" -type f -mtime +90 2>/dev/null | wc -l | tr -d ' ') 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 if [[ $old_files -gt 0 ]]; then
suggestions+=(" Clean old downloads: $old_files files older than 90 days") suggestions+=(" Clean old downloads: $old_files files older than 90 days")
fi fi
@@ -706,18 +748,22 @@ display_cleanup_suggestions() {
# Check Xcode derived data # Check Xcode derived data
if [[ -d "$HOME/Library/Developer/Xcode/DerivedData" ]]; then if [[ -d "$HOME/Library/Developer/Xcode/DerivedData" ]]; then
local xcode_size=$(du -sk "$HOME/Library/Developer/Xcode/DerivedData" 2>/dev/null | cut -f1) local xcode_size
xcode_size=$(du -sk "$HOME/Library/Developer/Xcode/DerivedData" 2>/dev/null | cut -f1)
if [[ $xcode_size -gt 10485760 ]]; then # > 10GB if [[ $xcode_size -gt 10485760 ]]; then # > 10GB
local human=$(bytes_to_human $((xcode_size * 1024))) local human
human=$(bytes_to_human $((xcode_size * 1024)))
suggestions+=(" Clear Xcode cache: $human") suggestions+=(" Clear Xcode cache: $human")
fi fi
fi fi
# Check iOS device backups # Check iOS device backups
if [[ -d "$HOME/Library/Application Support/MobileSync/Backup" ]]; then 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) 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 if [[ $backup_size -gt 5242880 ]]; then # > 5GB
local human=$(bytes_to_human $((backup_size * 1024))) local human
human=$(bytes_to_human $((backup_size * 1024)))
suggestions+=(" 📱 Review iOS backups: $human") suggestions+=(" 📱 Review iOS backups: $human")
fi fi
fi fi
@@ -728,7 +774,8 @@ display_cleanup_suggestions() {
mdfind -onlyin "$CURRENT_PATH" "kMDItemFSSize > 10000000" 2>/dev/null | \ mdfind -onlyin "$CURRENT_PATH" "kMDItemFSSize > 10000000" 2>/dev/null | \
xargs -I {} stat -f "%z" {} 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" 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") local dup_count
dup_count=$(cat "$temp_dup" 2>/dev/null || echo "0")
if [[ $dup_count -gt 5 ]]; then if [[ $dup_count -gt 5 ]]; then
suggestions+=(" ♻️ Possible duplicates: $dup_count size matches in large files (>10MB)") suggestions+=(" ♻️ Possible duplicates: $dup_count size matches in large files (>10MB)")
fi fi
@@ -772,11 +819,13 @@ display_disk_summary() {
log_header "Disk Situation" log_header "Disk Situation"
local target_display=$(echo "$CURRENT_PATH" | sed "s|^$HOME|~|") local target_display
target_display=$(echo "$CURRENT_PATH" | sed "s|^$HOME|~|")
echo " ${BLUE}Scanning:${NC} $target_display | ${BLUE}Free:${NC} $(get_free_space)" echo " ${BLUE}Scanning:${NC} $target_display | ${BLUE}Free:${NC} $(get_free_space)"
if [[ $total_large_count -gt 0 ]]; then if [[ $total_large_count -gt 0 ]]; then
local large_human=$(bytes_to_human "$total_large_size") 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" 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 elif [[ $total_dirs_size -gt 0 ]]; then
echo " ${BLUE}Total Scanned:${NC} $(bytes_to_human "$total_dirs_size") across $total_dirs_count directories" echo " ${BLUE}Total Scanned:${NC} $(bytes_to_human "$total_dirs_size") across $total_dirs_count directories"
@@ -820,10 +869,14 @@ get_file_age() {
return return
fi fi
local mtime=$(stat -f%m "$path" 2>/dev/null || echo "0") local mtime
local now=$(date +%s) mtime=$(stat -f%m "$path" 2>/dev/null || echo "0")
local diff=$((now - mtime)) local now
local days=$((diff / 86400)) now=$(date +%s)
local diff
diff=$((now - mtime))
local days
days=$((diff / 86400))
if [[ $days -lt 1 ]]; then if [[ $days -lt 1 ]]; then
echo "Today" echo "Today"
@@ -832,10 +885,12 @@ get_file_age() {
elif [[ $days -lt 30 ]]; then elif [[ $days -lt 30 ]]; then
echo "${days}d" echo "${days}d"
elif [[ $days -lt 365 ]]; then elif [[ $days -lt 365 ]]; then
local months=$((days / 30)) local months
months=$((days / 30))
echo "${months}mo" echo "${months}mo"
else else
local years=$((days / 365)) local years
years=$((days / 365))
echo "${years}yr" echo "${years}yr"
fi fi
} }
@@ -860,13 +915,17 @@ display_large_files_table() {
break break
fi fi
local human_size=$(bytes_to_human "$size") local human_size
local filename=$(basename "$path") human_size=$(bytes_to_human "$size")
local filename
filename=$(basename "$path")
local ext="${filename##*.}" local ext="${filename##*.}"
local age=$(get_file_age "$path") local age
age=$(get_file_age "$path")
# Get file info and badge # Get file info and badge
local info=$(get_file_info "$path") local info
info=$(get_file_info "$path")
local badge="${info%|*}" local badge="${info%|*}"
# Truncate filename if too long # Truncate filename if too long
@@ -889,7 +948,8 @@ display_large_files_table() {
((count++)) ((count++))
done < "$temp_large" done < "$temp_large"
local total=$(wc -l < "$temp_large" | tr -d ' ') local total
total=$(wc -l < "$temp_large" | tr -d ' ')
if [[ $total -gt 20 ]]; then if [[ $total -gt 20 ]]; then
echo " ${GRAY}... $((total - 20)) more files${NC}" echo " ${GRAY}... $((total - 20)) more files${NC}"
fi fi
@@ -925,19 +985,24 @@ display_unified_directories() {
break break
fi fi
local percentage=$((size * 100 / total_size)) local percentage
local bar_width=$((percentage * chart_width / 100)) percentage=$((size * 100 / total_size))
local bar_width
bar_width=$((percentage * chart_width / 100))
[[ $bar_width -lt 1 ]] && bar_width=1 [[ $bar_width -lt 1 ]] && bar_width=1
local dirname=$(basename "$path") local dirname
local human_size=$(bytes_to_human "$size") dirname=$(basename "$path")
local human_size
human_size=$(bytes_to_human "$size")
# Build compact bar # Build compact bar
local bar="" local bar=""
if [[ $bar_width -gt 0 ]]; then if [[ $bar_width -gt 0 ]]; then
bar=$(printf "%${bar_width}s" "" | tr ' ' '▓') bar=$(printf "%${bar_width}s" "" | tr ' ' '▓')
fi fi
local empty=$((chart_width - bar_width)) local empty
empty=$((chart_width - bar_width))
if [[ $empty -gt 0 ]]; then if [[ $empty -gt 0 ]]; then
bar="${bar}$(printf "%${empty}s" "" | tr ' ' '░')" bar="${bar}$(printf "%${empty}s" "" | tr ' ' '░')"
fi fi
@@ -1009,12 +1074,16 @@ display_space_chart() {
break break
fi fi
local percentage=$((size * 100 / total_size)) local percentage
local bar_width=$((percentage * chart_width / 100)) percentage=$((size * 100 / total_size))
local bar_width
bar_width=$((percentage * chart_width / 100))
[[ $bar_width -lt 1 ]] && bar_width=1 [[ $bar_width -lt 1 ]] && bar_width=1
local dirname=$(basename "$path") local dirname
local human_size=$(bytes_to_human "$size") dirname=$(basename "$path")
local human_size
human_size=$(bytes_to_human "$size")
# Build visual bar # Build visual bar
local bar="" local bar=""
@@ -1048,8 +1117,10 @@ display_recent_large_files() {
"kMDItemFSSize > 100000000 && kMDItemContentCreationDate >= \$time.today(-30)" 2>/dev/null | \ "kMDItemFSSize > 100000000 && kMDItemContentCreationDate >= \$time.today(-30)" 2>/dev/null | \
while IFS= read -r file; do while IFS= read -r file; do
if [[ -f "$file" ]]; then if [[ -f "$file" ]]; then
local size=$(stat -f%z "$file" 2>/dev/null || echo "0") local size
local mtime=$(stat -f%m "$file" 2>/dev/null || echo "0") 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" echo "$size|$mtime|$file"
fi fi
done | sort -t'|' -k1 -rn | head -10 > "$temp_recent" done | sort -t'|' -k1 -rn | head -10 > "$temp_recent"
@@ -1062,12 +1133,17 @@ display_recent_large_files() {
local count=0 local count=0
while IFS='|' read -r size mtime path; do while IFS='|' read -r size mtime path; do
local human_size=$(bytes_to_human "$size") local human_size
local filename=$(basename "$path") human_size=$(bytes_to_human "$size")
local dirname=$(dirname "$path" | sed "s|^$HOME|~|") local filename
local days_ago=$(( ($(date +%s) - mtime) / 86400 )) filename=$(basename "$path")
local dirname
dirname=$(dirname "$path" | sed "s|^$HOME|~|")
local days_ago
days_ago=$(( ($(date +%s) - mtime) / 86400 ))
local info=$(get_file_info "$path") local info
info=$(get_file_info "$path")
local badge="${info%|*}" local badge="${info%|*}"
printf " %s %s ${GRAY}(%s)${NC}\n" "$badge" "$filename" "$human_size" printf " %s %s ${GRAY}(%s)${NC}\n" "$badge" "$filename" "$human_size"
@@ -1088,7 +1164,8 @@ get_subdirectories() {
find "$target" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | \ find "$target" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | \
while IFS= read -r dir; do while IFS= read -r dir; do
local size=$(du -sk "$dir" 2>/dev/null | cut -f1) local size
size=$(du -sk "$dir" 2>/dev/null | cut -f1)
echo "$((size * 1024))|$dir" echo "$((size * 1024))|$dir"
done | sort -t'|' -k1 -rn > "$temp_file" done | sort -t'|' -k1 -rn > "$temp_file"
} }
@@ -1116,11 +1193,16 @@ display_directory_list() {
# Display with cursor # Display with cursor
while IFS='|' read -r size path; do while IFS='|' read -r size path; do
local human_size=$(bytes_to_human "$size") local human_size
local percentage=$(calc_percentage "$size" "$total_size") human_size=$(bytes_to_human "$size")
local bar=$(generate_bar "$size" "$max_size" 20) local percentage
local display_path=$(echo "$path" | sed "s|^$HOME|~|") percentage=$(calc_percentage "$size" "$total_size")
local dirname=$(basename "$path") 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 # Highlight selected line
if [[ $idx -eq $cursor_pos ]]; then if [[ $idx -eq $cursor_pos ]]; then
@@ -1168,7 +1250,8 @@ count_directories() {
echo "0" echo "0"
return return
fi fi
local count=$(wc -l < "$temp_dirs" | tr -d ' ') local count
count=$(wc -l < "$temp_dirs" | tr -d ' ')
[[ $count -gt 15 ]] && count=15 [[ $count -gt 15 ]] && count=15
echo "$count" echo "$count"
} }
@@ -1252,20 +1335,24 @@ display_file_types() {
;; ;;
esac esac
local files=$(mdfind -onlyin "$CURRENT_PATH" "$query" 2>/dev/null) local files
local count=$(echo "$files" | grep -c . || echo "0") files=$(mdfind -onlyin "$CURRENT_PATH" "$query" 2>/dev/null)
local count
count=$(echo "$files" | grep -c . || echo "0")
local total_size=0 local total_size=0
if [[ $count -gt 0 ]]; then if [[ $count -gt 0 ]]; then
while IFS= read -r file; do while IFS= read -r file; do
if [[ -f "$file" ]]; then if [[ -f "$file" ]]; then
local fsize=$(stat -f%z "$file" 2>/dev/null || echo "0") local fsize
fsize=$(stat -f%z "$file" 2>/dev/null || echo "0")
((total_size += fsize)) ((total_size += fsize))
fi fi
done <<< "$files" done <<< "$files"
if [[ $total_size -gt 0 ]]; then if [[ $total_size -gt 0 ]]; then
local human_size=$(bytes_to_human "$total_size") local human_size
human_size=$(bytes_to_human "$total_size")
printf " %s %-12s %8s (%d files)\n" "$badge" "$type_name:" "$human_size" "$count" printf " %s %-12s %8s (%d files)\n" "$badge" "$type_name:" "$human_size" "$count"
fi fi
fi fi
@@ -1292,7 +1379,8 @@ scan_directory_contents_fast() {
local show_progress="${4:-true}" local show_progress="${4:-true}"
# Auto-detect optimal parallel jobs using common function # Auto-detect optimal parallel jobs using common function
local num_jobs=$(get_optimal_parallel_jobs "io") local num_jobs
num_jobs=$(get_optimal_parallel_jobs "io")
# Cap at reasonable limits for I/O operations # Cap at reasonable limits for I/O operations
[[ $num_jobs -gt 24 ]] && num_jobs=24 [[ $num_jobs -gt 24 ]] && num_jobs=24
[[ $num_jobs -lt 12 ]] && num_jobs=12 [[ $num_jobs -lt 12 ]] && num_jobs=12
@@ -1456,7 +1544,8 @@ combine_initial_scan_results() {
if [[ -f "$temp_large" ]]; then if [[ -f "$temp_large" ]]; then
while IFS='|' read -r size path; do while IFS='|' read -r size path; do
# Only include if parent directory is the current scan path # Only include if parent directory is the current scan path
local parent=$(dirname "$path") local parent
parent=$(dirname "$path")
if [[ "$parent" == "$CURRENT_PATH" ]]; then if [[ "$parent" == "$CURRENT_PATH" ]]; then
echo "$size|file|$path" echo "$size|file|$path"
fi fi
@@ -1484,7 +1573,8 @@ show_volumes_overview() {
if [[ -d "/Volumes" ]]; then if [[ -d "/Volumes" ]]; then
local vol_priority=500 local vol_priority=500
find /Volumes -mindepth 1 -maxdepth 1 -type d 2>/dev/null | while IFS= read -r vol; do find /Volumes -mindepth 1 -maxdepth 1 -type d 2>/dev/null | while IFS= read -r vol; do
local vol_name=$(basename "$vol") local vol_name
vol_name=$(basename "$vol")
echo "$((vol_priority))|$vol|Volume: $vol_name" echo "$((vol_priority))|$vol|Volume: $vol_name"
((vol_priority--)) ((vol_priority--))
done done
@@ -1505,7 +1595,8 @@ show_volumes_overview() {
stty -echo 2>/dev/null || true stty -echo 2>/dev/null || true
local cursor=0 local cursor=0
local total_items=$(wc -l < "$temp_volumes" | tr -d ' ') local total_items
total_items=$(wc -l < "$temp_volumes" | tr -d ' ')
while true; do while true; do
# Ensure cursor is always hidden # Ensure cursor is always hidden
@@ -1655,7 +1746,8 @@ interactive_drill_down() {
# Only scan if needed (directory changed or refresh requested) # Only scan if needed (directory changed or refresh requested)
if [[ "$need_scan" == "true" ]]; then if [[ "$need_scan" == "true" ]]; then
# Generate cache key (use md5 hash of path) # Generate cache key (use md5 hash of path)
local cache_key=$(echo "$current_path" | md5 2>/dev/null || echo "$current_path" | shasum | cut -d' ' -f1) local cache_key
cache_key=$(echo "$current_path" | md5 2>/dev/null || echo "$current_path" | shasum | cut -d' ' -f1)
local cache_file="$cache_dir/$cache_key" local cache_file="$cache_dir/$cache_key"
# Check if we have cached results for this directory # Check if we have cached results for this directory
@@ -1732,7 +1824,8 @@ interactive_drill_down() {
if [[ ${#path_stack[@]} -gt 0 ]]; then if [[ ${#path_stack[@]} -gt 0 ]]; then
# Use bash 3.2 compatible way to get last element # Use bash 3.2 compatible way to get last element
local stack_size=${#path_stack[@]} local stack_size=${#path_stack[@]}
local last_index=$((stack_size - 1)) local last_index
last_index=$((stack_size - 1))
current_path="${path_stack[$last_index]}" current_path="${path_stack[$last_index]}"
unset "path_stack[$last_index]" unset "path_stack[$last_index]"
cursor=0 cursor=0
@@ -1757,7 +1850,8 @@ interactive_drill_down() {
local max_show=15 # Show 15 items per page local max_show=15 # Show 15 items per page
local page_start=$scroll_offset local page_start=$scroll_offset
local page_end=$((scroll_offset + max_show)) local page_end
page_end=$((scroll_offset + max_show))
[[ $page_end -gt $total_items ]] && page_end=$total_items [[ $page_end -gt $total_items ]] && page_end=$total_items
local display_idx=0 local display_idx=0
@@ -1778,7 +1872,8 @@ interactive_drill_down() {
local rest="${item_info#*|}" local rest="${item_info#*|}"
local type="${rest%%|*}" local type="${rest%%|*}"
local path="${rest#*|}" local path="${rest#*|}"
local name=$(basename "$path") local name
name=$(basename "$path")
local human_size local human_size
if [[ "$size" -eq 0 ]]; then if [[ "$size" -eq 0 ]]; then
@@ -1796,7 +1891,8 @@ interactive_drill_down() {
fi fi
else else
local ext="${name##*.}" local ext="${name##*.}"
local info=$(get_file_info "$path") local info
info=$(get_file_info "$path")
badge="${info%|*}" badge="${info%|*}"
case "$ext" in case "$ext" in
dmg|iso|pkg|zip|tar|gz|rar|7z) dmg|iso|pkg|zip|tar|gz|rar|7z)
@@ -1871,7 +1967,8 @@ interactive_drill_down() {
if [[ $cursor -lt $((total_items - 1)) ]]; then if [[ $cursor -lt $((total_items - 1)) ]]; then
((cursor++)) ((cursor++))
# Scroll down if cursor goes below visible area # Scroll down if cursor goes below visible area
local page_end=$((scroll_offset + max_show)) local page_end
page_end=$((scroll_offset + max_show))
if [[ $cursor -ge $page_end ]]; then if [[ $cursor -ge $page_end ]]; then
scroll_offset=$((cursor - max_show + 1)) scroll_offset=$((cursor - max_show + 1))
fi fi
@@ -1895,7 +1992,8 @@ interactive_drill_down() {
else else
# It's a file - open it for viewing # It's a file - open it for viewing
local file_ext="${selected_path##*.}" local file_ext="${selected_path##*.}"
local filename=$(basename "$selected_path") local filename
filename=$(basename "$selected_path")
local open_success=false local open_success=false
# For text-like files, use less or fallback to open # For text-like files, use less or fallback to open
@@ -1972,7 +2070,8 @@ interactive_drill_down() {
# Pop from stack and go back # Pop from stack and go back
# Use bash 3.2 compatible way to get last element # Use bash 3.2 compatible way to get last element
local stack_size=${#path_stack[@]} local stack_size=${#path_stack[@]}
local last_index=$((stack_size - 1)) local last_index
last_index=$((stack_size - 1))
current_path="${path_stack[$last_index]}" current_path="${path_stack[$last_index]}"
unset "path_stack[$last_index]" unset "path_stack[$last_index]"
cursor=0 cursor=0
@@ -2008,8 +2107,10 @@ interactive_drill_down() {
local rest="${selected#*|}" local rest="${selected#*|}"
local type="${rest%%|*}" local type="${rest%%|*}"
local selected_path="${rest#*|}" local selected_path="${rest#*|}"
local selected_name=$(basename "$selected_path") local selected_name
local human_size=$(bytes_to_human "$size") selected_name=$(basename "$selected_path")
local human_size
human_size=$(bytes_to_human "$size")
# Check if sudo is needed # Check if sudo is needed
local needs_sudo=false local needs_sudo=false
@@ -2034,7 +2135,8 @@ interactive_drill_down() {
if [[ "$type" == "dir" ]]; then if [[ "$type" == "dir" ]]; then
echo " ${BADGE_DIR} ${YELLOW}$selected_name${NC}" echo " ${BADGE_DIR} ${YELLOW}$selected_name${NC}"
else else
local info=$(get_file_info "$selected_path") local info
info=$(get_file_info "$selected_path")
local badge="${info%|*}" local badge="${info%|*}"
echo " $badge ${YELLOW}$selected_name${NC}" echo " $badge ${YELLOW}$selected_name${NC}"
fi fi
@@ -2092,7 +2194,8 @@ interactive_drill_down() {
sleep 0.8 sleep 0.8
# Clear cache to force rescan # Clear cache to force rescan
local cache_key=$(echo "$current_path" | md5 2>/dev/null || echo "$current_path" | shasum | cut -d' ' -f1) 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" local cache_file="$cache_dir/$cache_key"
rm -f "$cache_file" 2>/dev/null || true rm -f "$cache_file" 2>/dev/null || true
@@ -2142,7 +2245,8 @@ interactive_mode() {
type drain_pending_input >/dev/null 2>&1 && drain_pending_input type drain_pending_input >/dev/null 2>&1 && drain_pending_input
display_interactive_menu display_interactive_menu
local key=$(read_key) local key
key=$(read_key)
case "$key" in case "$key" in
"QUIT") "QUIT")
break break
@@ -2154,14 +2258,16 @@ interactive_mode() {
;; ;;
"DOWN") "DOWN")
if [[ "$VIEW_MODE" == "navigate" ]]; then if [[ "$VIEW_MODE" == "navigate" ]]; then
local max_count=$(count_directories) local max_count
max_count=$(count_directories)
((CURSOR_POS < max_count - 1)) && ((CURSOR_POS++)) ((CURSOR_POS < max_count - 1)) && ((CURSOR_POS++))
fi fi
;; ;;
"RIGHT") "RIGHT")
if [[ "$VIEW_MODE" == "navigate" ]]; then if [[ "$VIEW_MODE" == "navigate" ]]; then
# Enter selected directory # Enter selected directory
local selected_path=$(get_path_at_cursor "$CURSOR_POS") local selected_path
selected_path=$(get_path_at_cursor "$CURSOR_POS")
if [[ -n "$selected_path" ]] && [[ -d "$selected_path" ]]; then if [[ -n "$selected_path" ]] && [[ -d "$selected_path" ]]; then
CURRENT_PATH="$selected_path" CURRENT_PATH="$selected_path"
CURSOR_POS=0 CURSOR_POS=0
@@ -2194,7 +2300,8 @@ interactive_mode() {
"ENTER") "ENTER")
if [[ "$VIEW_MODE" == "navigate" ]]; then if [[ "$VIEW_MODE" == "navigate" ]]; then
# Same as RIGHT # Same as RIGHT
local selected_path=$(get_path_at_cursor "$CURSOR_POS") local selected_path
selected_path=$(get_path_at_cursor "$CURSOR_POS")
if [[ -n "$selected_path" ]] && [[ -d "$selected_path" ]]; then if [[ -n "$selected_path" ]] && [[ -d "$selected_path" ]]; then
CURRENT_PATH="$selected_path" CURRENT_PATH="$selected_path"
CURSOR_POS=0 CURSOR_POS=0
@@ -2231,7 +2338,8 @@ export_to_csv() {
{ {
echo "Size (Bytes),Size (Human),Path" echo "Size (Bytes),Size (Human),Path"
while IFS='|' read -r size path; do while IFS='|' read -r size path; do
local human=$(bytes_to_human "$size") local human
human=$(bytes_to_human "$size")
echo "$size,\"$human\",\"$path\"" echo "$size,\"$human\",\"$path\""
done < "$temp_dirs" done < "$temp_dirs"
} > "$output_file" } > "$output_file"
@@ -2260,7 +2368,8 @@ export_to_json() {
while IFS='|' read -r size path; do while IFS='|' read -r size path; do
[[ "$first" == "false" ]] && echo "," [[ "$first" == "false" ]] && echo ","
first=false first=false
local human=$(bytes_to_human "$size") local human
human=$(bytes_to_human "$size")
printf ' {"size": %d, "size_human": "%s", "path": "%s"}' "$size" "$human" "$path" printf ' {"size": %d, "size_human": "%s", "path": "%s"}' "$size" "$human" "$path"
done < "$temp_dirs" done < "$temp_dirs"
@@ -2273,7 +2382,8 @@ export_to_json() {
while IFS='|' read -r size path; do while IFS='|' read -r size path; do
[[ "$first" == "false" ]] && echo "," [[ "$first" == "false" ]] && echo ","
first=false first=false
local human=$(bytes_to_human "$size") local human
human=$(bytes_to_human "$size")
printf ' {"size": %d, "size_human": "%s", "path": "%s"}' "$size" "$human" "$path" printf ' {"size": %d, "size_human": "%s", "path": "%s"}' "$size" "$human" "$path"
done < "$temp_large" done < "$temp_large"
echo "" echo ""

View File

@@ -211,7 +211,9 @@ safe_clean() {
description="$1" description="$1"
targets=("$1") targets=("$1")
else else
description="${@: -1}" # Get last argument as description
description="${*: -1}"
# Get all arguments except last as targets array
targets=("${@:1:$#-1}") targets=("${@:1:$#-1}")
fi fi

View File

@@ -67,13 +67,16 @@ total_size_cleaned=0
# Get app last used date in human readable format # Get app last used date in human readable format
get_app_last_used() { get_app_last_used() {
local app_path="$1" local app_path="$1"
local last_used=$(mdls -name kMDItemLastUsedDate -raw "$app_path" 2>/dev/null) local last_used
last_used=$(mdls -name kMDItemLastUsedDate -raw "$app_path" 2>/dev/null)
if [[ "$last_used" == "(null)" || -z "$last_used" ]]; then if [[ "$last_used" == "(null)" || -z "$last_used" ]]; then
echo "Never" echo "Never"
else else
local last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$last_used" "+%s" 2>/dev/null) local last_used_epoch
local current_epoch=$(date "+%s") last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$last_used" "+%s" 2>/dev/null)
local current_epoch
current_epoch=$(date "+%s")
local days_ago=$(( (current_epoch - last_used_epoch) / 86400 )) local days_ago=$(( (current_epoch - last_used_epoch) / 86400 ))
if [[ $days_ago -eq 0 ]]; then if [[ $days_ago -eq 0 ]]; then
@@ -103,7 +106,8 @@ scan_applications() {
mkdir -p "$cache_dir" 2>/dev/null mkdir -p "$cache_dir" 2>/dev/null
# Quick count of current apps (system + user directories) # Quick count of current apps (system + user directories)
local current_app_count=$( local current_app_count
current_app_count=$(
(find /Applications -name "*.app" -maxdepth 1 2>/dev/null; (find /Applications -name "*.app" -maxdepth 1 2>/dev/null;
find ~/Applications -name "*.app" -maxdepth 1 2>/dev/null) | wc -l | tr -d ' ' find ~/Applications -name "*.app" -maxdepth 1 2>/dev/null) | wc -l | tr -d ' '
) )
@@ -111,7 +115,8 @@ scan_applications() {
# Check if cache is valid unless explicitly disabled # Check if cache is valid unless explicitly disabled
if [[ -f "$cache_file" && -f "$cache_meta" ]]; then if [[ -f "$cache_file" && -f "$cache_meta" ]]; then
local cache_age=$(($(date +%s) - $(stat -f%m "$cache_file" 2>/dev/null || echo 0))) local cache_age=$(($(date +%s) - $(stat -f%m "$cache_file" 2>/dev/null || echo 0)))
local cached_app_count=$(cat "$cache_meta" 2>/dev/null || echo "0") local cached_app_count
cached_app_count=$(cat "$cache_meta" 2>/dev/null || echo "0")
# Cache is valid if: age < TTL AND app count matches # Cache is valid if: age < TTL AND app count matches
if [[ $cache_age -lt $cache_ttl && "$cached_app_count" == "$current_app_count" ]]; then if [[ $cache_age -lt $cache_ttl && "$cached_app_count" == "$current_app_count" ]]; then
@@ -121,10 +126,12 @@ scan_applications() {
fi fi
fi fi
local temp_file=$(create_temp_file) local temp_file
temp_file=$(create_temp_file)
# Pre-cache current epoch to avoid repeated calls # Pre-cache current epoch to avoid repeated calls
local current_epoch=$(date "+%s") local current_epoch
current_epoch=$(date "+%s")
# Spinner for scanning feedback (simple ASCII for compatibility) # Spinner for scanning feedback (simple ASCII for compatibility)
local spinner_chars="|/-\\" local spinner_chars="|/-\\"
@@ -135,7 +142,8 @@ scan_applications() {
while IFS= read -r -d '' app_path; do while IFS= read -r -d '' app_path; do
if [[ ! -e "$app_path" ]]; then continue; fi if [[ ! -e "$app_path" ]]; then continue; fi
local app_name=$(basename "$app_path" .app) local app_name
app_name=$(basename "$app_path" .app)
# Try to get English name from bundle info, fallback to folder name # Try to get English name from bundle info, fallback to folder name
local bundle_id="unknown" local bundle_id="unknown"
@@ -144,14 +152,17 @@ scan_applications() {
bundle_id=$(defaults read "$app_path/Contents/Info.plist" CFBundleIdentifier 2>/dev/null || echo "unknown") bundle_id=$(defaults read "$app_path/Contents/Info.plist" CFBundleIdentifier 2>/dev/null || echo "unknown")
# Try to get English name from bundle info # Try to get English name from bundle info
local bundle_executable=$(defaults read "$app_path/Contents/Info.plist" CFBundleExecutable 2>/dev/null) local bundle_executable
bundle_executable=$(defaults read "$app_path/Contents/Info.plist" CFBundleExecutable 2>/dev/null)
# Smart display name selection - prefer descriptive names over generic ones # Smart display name selection - prefer descriptive names over generic ones
local candidates=() local candidates=()
# Get all potential names # Get all potential names
local bundle_display_name=$(plutil -extract CFBundleDisplayName raw "$app_path/Contents/Info.plist" 2>/dev/null) local bundle_display_name
local bundle_name=$(plutil -extract CFBundleName raw "$app_path/Contents/Info.plist" 2>/dev/null) bundle_display_name=$(plutil -extract CFBundleDisplayName raw "$app_path/Contents/Info.plist" 2>/dev/null)
local bundle_name
bundle_name=$(plutil -extract CFBundleName raw "$app_path/Contents/Info.plist" 2>/dev/null)
# Check if executable name is generic/technical (should be avoided) # Check if executable name is generic/technical (should be avoided)
local is_generic_executable=false local is_generic_executable=false
@@ -242,7 +253,8 @@ scan_applications() {
local last_used_epoch=0 local last_used_epoch=0
if [[ -d "$app_path" ]]; then if [[ -d "$app_path" ]]; then
local metadata_date=$(mdls -name kMDItemLastUsedDate -raw "$app_path" 2>/dev/null) local metadata_date
metadata_date=$(mdls -name kMDItemLastUsedDate -raw "$app_path" 2>/dev/null)
if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then
last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2>/dev/null || echo "0") last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2>/dev/null || echo "0")
@@ -417,15 +429,20 @@ uninstall_applications() {
fi fi
# Find related files (user-level) # Find related files (user-level)
local related_files=$(find_app_files "$bundle_id" "$app_name") local related_files
related_files=$(find_app_files "$bundle_id" "$app_name")
# Find system-level files (requires sudo) # Find system-level files (requires sudo)
local system_files=$(find_app_system_files "$bundle_id" "$app_name") local system_files
system_files=$(find_app_system_files "$bundle_id" "$app_name")
# Calculate total size # Calculate total size
local app_size_kb=$(du -sk "$app_path" 2>/dev/null | awk '{print $1}' || echo "0") local app_size_kb
local related_size_kb=$(calculate_total_size "$related_files") app_size_kb=$(du -sk "$app_path" 2>/dev/null | awk '{print $1}' || echo "0")
local system_size_kb=$(calculate_total_size "$system_files") local related_size_kb
related_size_kb=$(calculate_total_size "$related_files")
local system_size_kb
system_size_kb=$(calculate_total_size "$system_files")
local total_kb=$((app_size_kb + related_size_kb + system_size_kb)) local total_kb=$((app_size_kb + related_size_kb + system_size_kb))
# Show what will be removed # Show what will be removed
@@ -619,20 +636,27 @@ main() {
if [[ $selection_count -eq 0 ]]; then if [[ $selection_count -eq 0 ]]; then
echo "No apps selected"; rm -f "$apps_file"; return 0 echo "No apps selected"; rm -f "$apps_file"; return 0
fi fi
# Compact one-line summary (list up to 3 names, aggregate rest) # Show selected apps, max 3 per line
local names=() echo -e "${BLUE}${ICON_CONFIRM}${NC} Selected ${selection_count} app(s):"
local idx=0 local idx=0
local line=""
for selected_app in "${selected_apps[@]}"; do for selected_app in "${selected_apps[@]}"; do
IFS='|' read -r epoch app_path app_name bundle_id size last_used <<< "$selected_app" IFS='|' read -r epoch app_path app_name bundle_id size last_used <<< "$selected_app"
if (( idx < 3 )); then local display_item="${app_name}(${size})"
names+=("${app_name}(${size})")
if (( idx % 3 == 0 )); then
# Start new line
[[ -n "$line" ]] && echo " $line"
line="$display_item"
else
# Add to current line
line="$line, $display_item"
fi fi
((idx++)) ((idx++))
done done
local extra=$((selection_count-3)) # Print the last line
local list="${names[*]}" [[ -n "$line" ]] && echo " $line"
[[ $extra -gt 0 ]] && list+=" +${extra}" echo ""
echo -e "${BLUE}${ICON_CONFIRM}${NC} ${selection_count} apps: ${list}"
# Execute batch uninstallation (handles confirmation) # Execute batch uninstallation (handles confirmation)
batch_uninstall_applications batch_uninstall_applications

View File

@@ -192,17 +192,42 @@ batch_uninstall_applications() {
if [[ -n "$freed_display" ]]; then if [[ -n "$freed_display" ]]; then
success_line+=", freed ${GREEN}${freed_display}${NC}" success_line+=", freed ${GREEN}${freed_display}${NC}"
fi fi
# Format app list with max 3 per line
if [[ -n "$success_list" ]]; then if [[ -n "$success_list" ]]; then
local -a formatted_apps=() local idx=0
local is_first_line=true
local current_line=""
for app_name in "${success_items[@]}"; do for app_name in "${success_items[@]}"; do
formatted_apps+=("${GREEN}${app_name}${NC}") local display_item="${GREEN}${app_name}${NC}"
if (( idx % 3 == 0 )); then
# Start new line
if [[ -n "$current_line" ]]; then
summary_details+=("$current_line")
fi
if [[ "$is_first_line" == true ]]; then
# First line: append to success_line
current_line="${success_line}: $display_item"
is_first_line=false
else
# Subsequent lines: just the apps
current_line="$display_item"
fi
else
# Add to current line
current_line="$current_line, $display_item"
fi
((idx++))
done done
if [[ ${#formatted_apps[@]} -gt 0 ]]; then # Add the last line
local IFS=', ' if [[ -n "$current_line" ]]; then
success_line+=": ${formatted_apps[*]}" summary_details+=("$current_line")
fi fi
else
summary_details+=("$success_line")
fi fi
summary_details+=("$success_line")
fi fi
if [[ $failed_count -gt 0 ]]; then if [[ $failed_count -gt 0 ]]; then

View File

@@ -262,14 +262,8 @@ EOF
fi fi
done done
if [[ ${#selected_indices[@]} -eq 0 ]]; then # Allow empty selection - don't auto-select cursor position
local default_idx=$((top_index + cursor_pos)) # This fixes the bug where unselecting all items would still select the last cursor position
if [[ $default_idx -ge 0 && $default_idx -lt $total_items ]]; then
selected[default_idx]=true
selected_indices=("$default_idx")
fi
fi
local final_result="" local final_result=""
if [[ ${#selected_indices[@]} -gt 0 ]]; then if [[ ${#selected_indices[@]} -gt 0 ]]; then
local IFS=',' local IFS=','

18
mole
View File

@@ -47,7 +47,8 @@ check_for_updates() {
# Background version check (save to file, don't output) # Background version check (save to file, don't output)
( (
local latest=$(get_latest_version) local latest
latest=$(get_latest_version)
if [[ -n "$latest" && "$VERSION" != "$latest" && "$(printf '%s\n' "$VERSION" "$latest" | sort -V | head -1)" == "$VERSION" ]]; then if [[ -n "$latest" && "$VERSION" != "$latest" && "$(printf '%s\n' "$VERSION" "$latest" | sort -V | head -1)" == "$VERSION" ]]; then
printf "\nUpdate available: %s → %s, run %smo update%s\n\n" "$VERSION" "$latest" "$GREEN" "$NC" > "$msg_cache" printf "\nUpdate available: %s → %s, run %smo update%s\n\n" "$VERSION" "$latest" "$GREEN" "$NC" > "$msg_cache"
@@ -155,7 +156,8 @@ update_mole() {
fi fi
# Check for updates # Check for updates
local latest=$(get_latest_version) local latest
latest=$(get_latest_version)
if [[ -z "$latest" ]]; then if [[ -z "$latest" ]]; then
log_error "Unable to check for updates. Check network connection." log_error "Unable to check for updates. Check network connection."
@@ -449,9 +451,11 @@ show_main_menu() {
interactive_main_menu() { interactive_main_menu() {
# Show intro animation only once per terminal tab # Show intro animation only once per terminal tab
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
local tty_name=$(tty 2>/dev/null || echo "") local tty_name
tty_name=$(tty 2>/dev/null || echo "")
if [[ -n "$tty_name" ]]; then if [[ -n "$tty_name" ]]; then
local flag_file="/tmp/mole_intro_$(echo "$tty_name" | tr -c '[:alnum:]_' '_')" local flag_file
flag_file="/tmp/mole_intro_$(echo "$tty_name" | tr -c '[:alnum:]_' '_')"
if [[ ! -f "$flag_file" ]]; then if [[ ! -f "$flag_file" ]]; then
animate_mole_intro animate_mole_intro
touch "$flag_file" 2>/dev/null || true touch "$flag_file" 2>/dev/null || true
@@ -489,8 +493,10 @@ interactive_main_menu() {
# Drain any pending input to prevent touchpad scroll issues # Drain any pending input to prevent touchpad scroll issues
drain_pending_input drain_pending_input
local key=$(read_key) local key
[[ $? -ne 0 ]] && continue if ! key=$(read_key); then
continue
fi
case "$key" in case "$key" in
"UP") ((current_option > 1)) && ((current_option--)) ;; "UP") ((current_option > 1)) && ((current_option--)) ;;

View File

@@ -1,22 +0,0 @@
#!/usr/bin/env bash
# shellcheck disable=SC2329
# Helper stub definitions for uninstall tests
setup_uninstall_stubs() {
request_sudo_access() { return 0; }
start_inline_spinner() { :; }
stop_inline_spinner() { :; }
enter_alt_screen() { :; }
leave_alt_screen() { :; }
hide_cursor() { :; }
show_cursor() { :; }
remove_apps_from_dock() { :; }
pgrep() { return 1; }
pkill() { return 0; }
sudo() { return 0; }
export -f request_sudo_access start_inline_spinner stop_inline_spinner \
enter_alt_screen leave_alt_screen hide_cursor show_cursor \
remove_apps_from_dock pgrep pkill sudo || true
}

View File

@@ -4,26 +4,41 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
if ! command -v bats >/dev/null 2>&1; then if command -v shellcheck >/dev/null 2>&1; then
SHELLCHECK_TARGETS=()
while IFS= read -r file; do
SHELLCHECK_TARGETS+=("$file")
done < <(find "$PROJECT_ROOT/tests" -type f \( -name '*.bats' -o -name '*.sh' \) | sort)
if [[ ${#SHELLCHECK_TARGETS[@]} -gt 0 ]]; then
shellcheck --rcfile "$PROJECT_ROOT/.shellcheckrc" "${SHELLCHECK_TARGETS[@]}"
else
echo "No shell files to lint under tests/." >&2
fi
else
echo "shellcheck not found; skipping linting." >&2
fi
if command -v bats >/dev/null 2>&1; then
cd "$PROJECT_ROOT"
if [[ -z "${TERM:-}" ]]; then
export TERM="xterm-256color"
fi
if [[ $# -eq 0 ]]; then
set -- tests
fi
if [[ -t 1 ]]; then
bats -p "$@"
else
TERM="${TERM:-xterm-256color}" bats --tap "$@"
fi
else
cat <<'EOF' >&2 cat <<'EOF' >&2
bats is required to run Mole's test suite. bats is required to run Mole's test suite.
Install via Homebrew with 'brew install bats-core' or via npm with 'npm install -g bats'. Install via Homebrew with 'brew install bats-core' or via npm with 'npm install -g bats'.
EOF EOF
exit 1 exit 1
fi fi
cd "$PROJECT_ROOT"
if [[ -z "${TERM:-}" ]]; then
export TERM="xterm-256color"
fi
if [[ $# -eq 0 ]]; then
set -- tests
fi
if [[ -t 1 ]]; then
bats -p "$@"
else
TERM="${TERM:-xterm-256color}" bats --tap "$@"
fi

View File

@@ -77,8 +77,19 @@ EOF
set -euo pipefail set -euo pipefail
source "$PROJECT_ROOT/lib/common.sh" source "$PROJECT_ROOT/lib/common.sh"
source "$PROJECT_ROOT/lib/batch_uninstall.sh" source "$PROJECT_ROOT/lib/batch_uninstall.sh"
source "$PROJECT_ROOT/tests/helpers/uninstall_stubs.sh"
setup_uninstall_stubs # Test stubs
request_sudo_access() { return 0; }
start_inline_spinner() { :; }
stop_inline_spinner() { :; }
enter_alt_screen() { :; }
leave_alt_screen() { :; }
hide_cursor() { :; }
show_cursor() { :; }
remove_apps_from_dock() { :; }
pgrep() { return 1; }
pkill() { return 0; }
sudo() { return 0; }
app_bundle="$HOME/Applications/TestApp.app" app_bundle="$HOME/Applications/TestApp.app"
mkdir -p "$app_bundle" mkdir -p "$app_bundle"

29
tests/z_shellcheck.bats Normal file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bats
setup_file() {
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export PROJECT_ROOT
}
@test "shellcheck passes for test scripts" {
if ! command -v shellcheck >/dev/null 2>&1; then
skip "shellcheck not installed"
fi
run env PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
cd "$PROJECT_ROOT"
targets=()
while IFS= read -r file; do
targets+=("$file")
done < <(find "$PROJECT_ROOT/tests" -type f \( -name '*.bats' -o -name '*.sh' \) | sort)
if [[ ${#targets[@]} -eq 0 ]]; then
echo "No test shell files found"
exit 0
fi
shellcheck --rcfile "$PROJECT_ROOT/.shellcheckrc" "${targets[@]}"
EOF
printf '%s\n' "$output" >&3
[ "$status" -eq 0 ]
}