mirror of
https://github.com/tw93/Mole.git
synced 2026-02-04 16:49:41 +00:00
855 lines
30 KiB
Bash
855 lines
30 KiB
Bash
#!/bin/bash
|
|
# Project Purge Module (mo purge)
|
|
# Removes heavy project build artifacts and dependencies
|
|
set -euo pipefail
|
|
|
|
PROJECT_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
CORE_LIB_DIR="$(cd "$PROJECT_LIB_DIR/../core" && pwd)"
|
|
if ! command -v ensure_user_dir > /dev/null 2>&1; then
|
|
# shellcheck disable=SC1090
|
|
source "$CORE_LIB_DIR/common.sh"
|
|
fi
|
|
|
|
# Targets to look for (heavy build artifacts)
|
|
readonly PURGE_TARGETS=(
|
|
"node_modules"
|
|
"target" # Rust, Maven
|
|
"build" # Gradle, various
|
|
"dist" # JS builds
|
|
"venv" # Python
|
|
".venv" # Python
|
|
".gradle" # Gradle local
|
|
"__pycache__" # Python
|
|
".next" # Next.js
|
|
".nuxt" # Nuxt.js
|
|
".output" # Nuxt.js
|
|
"vendor" # PHP Composer
|
|
"obj" # C# / Unity
|
|
".turbo" # Turborepo cache
|
|
".parcel-cache" # Parcel bundler
|
|
".dart_tool" # Flutter/Dart build cache
|
|
)
|
|
# Minimum age in days before considering for cleanup
|
|
readonly MIN_AGE_DAYS=7
|
|
# Scan depth defaults (relative to search root)
|
|
readonly PURGE_MIN_DEPTH_DEFAULT=2
|
|
readonly PURGE_MAX_DEPTH_DEFAULT=8
|
|
# Search paths (default, can be overridden via config file)
|
|
readonly DEFAULT_PURGE_SEARCH_PATHS=(
|
|
"$HOME/www"
|
|
"$HOME/dev"
|
|
"$HOME/Projects"
|
|
"$HOME/GitHub"
|
|
"$HOME/Code"
|
|
"$HOME/Workspace"
|
|
"$HOME/Repos"
|
|
"$HOME/Development"
|
|
)
|
|
|
|
# Config file for custom purge paths
|
|
readonly PURGE_CONFIG_FILE="$HOME/.config/mole/purge_paths"
|
|
|
|
# Global array to hold actual search paths
|
|
PURGE_SEARCH_PATHS=()
|
|
|
|
# Project indicator files (if a directory contains these, it's likely a project)
|
|
readonly PROJECT_INDICATORS=(
|
|
"package.json"
|
|
"Cargo.toml"
|
|
"go.mod"
|
|
"pyproject.toml"
|
|
"requirements.txt"
|
|
"pom.xml"
|
|
"build.gradle"
|
|
"Gemfile"
|
|
"composer.json"
|
|
"pubspec.yaml"
|
|
"Makefile"
|
|
".git"
|
|
)
|
|
|
|
# Check if a directory contains projects (directly or in subdirectories)
|
|
is_project_container() {
|
|
local dir="$1"
|
|
local max_depth="${2:-2}"
|
|
|
|
# Skip hidden directories and system directories
|
|
local basename
|
|
basename=$(basename "$dir")
|
|
[[ "$basename" == .* ]] && return 1
|
|
[[ "$basename" == "Library" ]] && return 1
|
|
[[ "$basename" == "Applications" ]] && return 1
|
|
[[ "$basename" == "Movies" ]] && return 1
|
|
[[ "$basename" == "Music" ]] && return 1
|
|
[[ "$basename" == "Pictures" ]] && return 1
|
|
[[ "$basename" == "Public" ]] && return 1
|
|
|
|
# Build find expression with all indicators (single find call for efficiency)
|
|
local -a find_args=("$dir" "-maxdepth" "$max_depth" "(")
|
|
local first=true
|
|
for indicator in "${PROJECT_INDICATORS[@]}"; do
|
|
if [[ "$first" == "true" ]]; then
|
|
first=false
|
|
else
|
|
find_args+=("-o")
|
|
fi
|
|
find_args+=("-name" "$indicator")
|
|
done
|
|
find_args+=(")" "-print" "-quit")
|
|
|
|
# Single find call to check all indicators at once
|
|
if find "${find_args[@]}" 2> /dev/null | grep -q .; then
|
|
return 0
|
|
fi
|
|
|
|
return 1
|
|
}
|
|
|
|
# Discover project directories in $HOME
|
|
discover_project_dirs() {
|
|
local -a discovered=()
|
|
|
|
# First check default paths that exist
|
|
for path in "${DEFAULT_PURGE_SEARCH_PATHS[@]}"; do
|
|
if [[ -d "$path" ]]; then
|
|
discovered+=("$path")
|
|
fi
|
|
done
|
|
|
|
# Then scan $HOME for other project containers (depth 1)
|
|
local dir
|
|
for dir in "$HOME"/*/; do
|
|
[[ ! -d "$dir" ]] && continue
|
|
dir="${dir%/}" # Remove trailing slash
|
|
|
|
# Skip if already in defaults
|
|
local already_found=false
|
|
for existing in "${DEFAULT_PURGE_SEARCH_PATHS[@]}"; do
|
|
if [[ "$dir" == "$existing" ]]; then
|
|
already_found=true
|
|
break
|
|
fi
|
|
done
|
|
[[ "$already_found" == "true" ]] && continue
|
|
|
|
# Check if this directory contains projects
|
|
if is_project_container "$dir" 2; then
|
|
discovered+=("$dir")
|
|
fi
|
|
done
|
|
|
|
# Return unique paths
|
|
printf '%s\n' "${discovered[@]}" | sort -u
|
|
}
|
|
|
|
# Save discovered paths to config
|
|
save_discovered_paths() {
|
|
local -a paths=("$@")
|
|
|
|
ensure_user_dir "$(dirname "$PURGE_CONFIG_FILE")"
|
|
|
|
cat > "$PURGE_CONFIG_FILE" << 'EOF'
|
|
# Mole Purge Paths - Auto-discovered project directories
|
|
# Edit this file to customize, or run: mo purge --paths
|
|
# Add one path per line (supports ~ for home directory)
|
|
EOF
|
|
|
|
printf '\n' >> "$PURGE_CONFIG_FILE"
|
|
for path in "${paths[@]}"; do
|
|
# Convert $HOME to ~ for portability
|
|
path="${path/#$HOME/~}"
|
|
echo "$path" >> "$PURGE_CONFIG_FILE"
|
|
done
|
|
}
|
|
|
|
# Load purge paths from config or auto-discover
|
|
load_purge_config() {
|
|
PURGE_SEARCH_PATHS=()
|
|
|
|
# Try loading from config file
|
|
if [[ -f "$PURGE_CONFIG_FILE" ]]; then
|
|
while IFS= read -r line; do
|
|
# Remove leading/trailing whitespace
|
|
line="${line#"${line%%[![:space:]]*}"}"
|
|
line="${line%"${line##*[![:space:]]}"}"
|
|
|
|
# Skip empty lines and comments
|
|
[[ -z "$line" || "$line" =~ ^# ]] && continue
|
|
|
|
# Expand tilde to HOME
|
|
line="${line/#\~/$HOME}"
|
|
|
|
PURGE_SEARCH_PATHS+=("$line")
|
|
done < "$PURGE_CONFIG_FILE"
|
|
fi
|
|
|
|
# If no paths loaded, auto-discover and save
|
|
if [[ ${#PURGE_SEARCH_PATHS[@]} -eq 0 ]]; then
|
|
# Show discovery message if running interactively
|
|
if [[ -t 1 ]] && [[ -z "${_PURGE_DISCOVERY_SILENT:-}" ]]; then
|
|
echo -e "${GRAY}First run: discovering project directories...${NC}" >&2
|
|
fi
|
|
|
|
local -a discovered=()
|
|
while IFS= read -r path; do
|
|
[[ -n "$path" ]] && discovered+=("$path")
|
|
done < <(discover_project_dirs)
|
|
|
|
if [[ ${#discovered[@]} -gt 0 ]]; then
|
|
PURGE_SEARCH_PATHS=("${discovered[@]}")
|
|
# Save for next time
|
|
save_discovered_paths "${discovered[@]}"
|
|
|
|
if [[ -t 1 ]] && [[ -z "${_PURGE_DISCOVERY_SILENT:-}" ]]; then
|
|
echo -e "${GRAY}Found ${#discovered[@]} project directories, saved to config${NC}" >&2
|
|
fi
|
|
else
|
|
# Fallback to defaults if nothing found
|
|
PURGE_SEARCH_PATHS=("${DEFAULT_PURGE_SEARCH_PATHS[@]}")
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# Initialize paths on script load
|
|
load_purge_config
|
|
|
|
# Args: $1 - path to check
|
|
# Check if path is safe to clean (must be inside a project directory)
|
|
is_safe_project_artifact() {
|
|
local path="$1"
|
|
local search_path="$2"
|
|
# Path must be absolute
|
|
if [[ "$path" != /* ]]; then
|
|
return 1
|
|
fi
|
|
# Must not be a direct child of HOME directory
|
|
# e.g., ~/.gradle is NOT safe, but ~/Projects/foo/.gradle IS safe
|
|
local relative_path="${path#"$search_path"/}"
|
|
local depth=$(echo "$relative_path" | tr -cd '/' | wc -c)
|
|
# Require at least 1 level deep (inside a project folder)
|
|
# e.g., ~/www/weekly/node_modules is OK (depth >= 1)
|
|
# but ~/www/node_modules is NOT OK (depth < 1)
|
|
if [[ $depth -lt 1 ]]; then
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
# Fast scan using fd or optimized find
|
|
# Args: $1 - search path, $2 - output file
|
|
# Args: $1 - search path, $2 - output file
|
|
# Scan for purge targets using strict project boundary checks
|
|
scan_purge_targets() {
|
|
local search_path="$1"
|
|
local output_file="$2"
|
|
local min_depth="${MOLE_PURGE_MIN_DEPTH:-$PURGE_MIN_DEPTH_DEFAULT}"
|
|
local max_depth="${MOLE_PURGE_MAX_DEPTH:-$PURGE_MAX_DEPTH_DEFAULT}"
|
|
if [[ ! "$min_depth" =~ ^[0-9]+$ ]]; then
|
|
min_depth="$PURGE_MIN_DEPTH_DEFAULT"
|
|
fi
|
|
if [[ ! "$max_depth" =~ ^[0-9]+$ ]]; then
|
|
max_depth="$PURGE_MAX_DEPTH_DEFAULT"
|
|
fi
|
|
if [[ "$max_depth" -lt "$min_depth" ]]; then
|
|
max_depth="$min_depth"
|
|
fi
|
|
if [[ ! -d "$search_path" ]]; then
|
|
return
|
|
fi
|
|
# Use fd for fast parallel search if available
|
|
if command -v fd > /dev/null 2>&1; then
|
|
local fd_args=(
|
|
"--absolute-path"
|
|
"--hidden"
|
|
"--no-ignore"
|
|
"--type" "d"
|
|
"--min-depth" "$min_depth"
|
|
"--max-depth" "$max_depth"
|
|
"--threads" "4"
|
|
"--exclude" ".git"
|
|
"--exclude" "Library"
|
|
"--exclude" ".Trash"
|
|
"--exclude" "Applications"
|
|
)
|
|
for target in "${PURGE_TARGETS[@]}"; do
|
|
fd_args+=("-g" "$target")
|
|
done
|
|
# Run fd command
|
|
fd "${fd_args[@]}" . "$search_path" 2> /dev/null | while IFS= read -r item; do
|
|
if is_safe_project_artifact "$item" "$search_path"; then
|
|
echo "$item"
|
|
fi
|
|
done | filter_nested_artifacts > "$output_file"
|
|
else
|
|
# Fallback to optimized find with pruning
|
|
# This prevents descending into heavily nested dirs like node_modules once found,
|
|
# providing a massive speedup (O(project_dirs) vs O(files)).
|
|
local prune_args=()
|
|
# 1. Directories to prune (ignore completely)
|
|
local prune_dirs=(".git" "Library" ".Trash" "Applications")
|
|
for dir in "${prune_dirs[@]}"; do
|
|
# -name "DIR" -prune -o
|
|
prune_args+=("-name" "$dir" "-prune" "-o")
|
|
done
|
|
# 2. Targets to find (print AND prune)
|
|
# If we find node_modules, we print it and STOP looking inside it
|
|
for target in "${PURGE_TARGETS[@]}"; do
|
|
# -name "TARGET" -print -prune -o
|
|
prune_args+=("-name" "$target" "-print" "-prune" "-o")
|
|
done
|
|
# Run find command
|
|
# Logic: ( prune_pattern -prune -o target_pattern -print -prune )
|
|
# Note: We rely on implicit recursion for directories that don't match any pattern.
|
|
# -print is only called explicitly on targets.
|
|
# Removing the trailing -o from loop construction if necessary?
|
|
# Actually my loop adds -o at the end. I need to handle that.
|
|
# Let's verify the array construction.
|
|
# Re-building args cleanly:
|
|
local find_expr=()
|
|
# Excludes
|
|
for dir in "${prune_dirs[@]}"; do
|
|
find_expr+=("-name" "$dir" "-prune" "-o")
|
|
done
|
|
# Targets
|
|
local i=0
|
|
for target in "${PURGE_TARGETS[@]}"; do
|
|
find_expr+=("-name" "$target" "-print" "-prune")
|
|
# Add -o unless it's the very last item of targets
|
|
if [[ $i -lt $((${#PURGE_TARGETS[@]} - 1)) ]]; then
|
|
find_expr+=("-o")
|
|
fi
|
|
((i++))
|
|
done
|
|
command find "$search_path" -mindepth "$min_depth" -maxdepth "$max_depth" -type d \
|
|
\( "${find_expr[@]}" \) 2> /dev/null | while IFS= read -r item; do
|
|
if is_safe_project_artifact "$item" "$search_path"; then
|
|
echo "$item"
|
|
fi
|
|
done | filter_nested_artifacts > "$output_file"
|
|
fi
|
|
}
|
|
# Filter out nested artifacts (e.g. node_modules inside node_modules)
|
|
filter_nested_artifacts() {
|
|
while IFS= read -r item; do
|
|
local parent_dir=$(dirname "$item")
|
|
local is_nested=false
|
|
for target in "${PURGE_TARGETS[@]}"; do
|
|
# Check if parent directory IS a target or IS INSIDE a target
|
|
# e.g. .../node_modules/foo/node_modules -> parent has node_modules
|
|
# Use more strict matching to avoid false positives like "my_node_modules_backup"
|
|
if [[ "$parent_dir" == *"/$target/"* || "$parent_dir" == *"/$target" ]]; then
|
|
is_nested=true
|
|
break
|
|
fi
|
|
done
|
|
if [[ "$is_nested" == "false" ]]; then
|
|
echo "$item"
|
|
fi
|
|
done
|
|
}
|
|
# Args: $1 - path
|
|
# Check if a path was modified recently (safety check)
|
|
is_recently_modified() {
|
|
local path="$1"
|
|
local age_days=$MIN_AGE_DAYS
|
|
if [[ ! -e "$path" ]]; then
|
|
return 1
|
|
fi
|
|
# Get modification time using base.sh helper (handles GNU vs BSD stat)
|
|
local mod_time
|
|
mod_time=$(get_file_mtime "$path")
|
|
local current_time=$(date +%s)
|
|
local age_seconds=$((current_time - mod_time))
|
|
local age_in_days=$((age_seconds / 86400))
|
|
if [[ $age_in_days -lt $age_days ]]; then
|
|
return 0 # Recently modified
|
|
else
|
|
return 1 # Old enough to clean
|
|
fi
|
|
}
|
|
# Args: $1 - path
|
|
# Get human-readable size of directory
|
|
get_dir_size_kb() {
|
|
local path="$1"
|
|
if [[ -d "$path" ]]; then
|
|
du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0"
|
|
else
|
|
echo "0"
|
|
fi
|
|
}
|
|
# Simple category selector (for purge only)
|
|
# Args: category names and metadata as arrays (passed via global vars)
|
|
# Uses PURGE_RECENT_CATEGORIES to mark categories with recent items (default unselected)
|
|
# Returns: selected indices in PURGE_SELECTION_RESULT (comma-separated)
|
|
select_purge_categories() {
|
|
local -a categories=("$@")
|
|
local total_items=${#categories[@]}
|
|
local clear_line=$'\r\033[2K'
|
|
if [[ $total_items -eq 0 ]]; then
|
|
return 1
|
|
fi
|
|
|
|
# Calculate items per page based on terminal height
|
|
# Reserved: header(2) + blank(2) + footer(1) = 5 rows
|
|
_get_items_per_page() {
|
|
local term_height=24
|
|
if [[ -t 0 ]] || [[ -t 2 ]]; then
|
|
term_height=$(stty size < /dev/tty 2> /dev/null | awk '{print $1}')
|
|
fi
|
|
if [[ -z "$term_height" || $term_height -le 0 ]]; then
|
|
if command -v tput > /dev/null 2>&1; then
|
|
term_height=$(tput lines 2> /dev/null || echo "24")
|
|
else
|
|
term_height=24
|
|
fi
|
|
fi
|
|
local reserved=6
|
|
local available=$((term_height - reserved))
|
|
if [[ $available -lt 3 ]]; then
|
|
echo 3
|
|
elif [[ $available -gt 50 ]]; then
|
|
echo 50
|
|
else
|
|
echo "$available"
|
|
fi
|
|
}
|
|
|
|
local items_per_page=$(_get_items_per_page)
|
|
local cursor_pos=0
|
|
local top_index=0
|
|
|
|
# Initialize selection (all selected by default, except recent ones)
|
|
local -a selected=()
|
|
IFS=',' read -r -a recent_flags <<< "${PURGE_RECENT_CATEGORIES:-}"
|
|
for ((i = 0; i < total_items; i++)); do
|
|
# Default unselected if category has recent items
|
|
if [[ ${recent_flags[i]:-false} == "true" ]]; then
|
|
selected[i]=false
|
|
else
|
|
selected[i]=true
|
|
fi
|
|
done
|
|
local original_stty=""
|
|
if [[ -t 0 ]] && command -v stty > /dev/null 2>&1; then
|
|
original_stty=$(stty -g 2> /dev/null || echo "")
|
|
fi
|
|
# Terminal control functions
|
|
restore_terminal() {
|
|
trap - EXIT INT TERM
|
|
show_cursor
|
|
if [[ -n "${original_stty:-}" ]]; then
|
|
stty "${original_stty}" 2> /dev/null || stty sane 2> /dev/null || true
|
|
fi
|
|
}
|
|
# shellcheck disable=SC2329
|
|
handle_interrupt() {
|
|
restore_terminal
|
|
exit 130
|
|
}
|
|
draw_menu() {
|
|
# Recalculate items_per_page dynamically to handle window resize
|
|
items_per_page=$(_get_items_per_page)
|
|
|
|
# Clamp pagination state to avoid cursor drifting out of view
|
|
local max_top_index=0
|
|
if [[ $total_items -gt $items_per_page ]]; then
|
|
max_top_index=$((total_items - items_per_page))
|
|
fi
|
|
if [[ $top_index -gt $max_top_index ]]; then
|
|
top_index=$max_top_index
|
|
fi
|
|
if [[ $top_index -lt 0 ]]; then
|
|
top_index=0
|
|
fi
|
|
|
|
local visible_count=$((total_items - top_index))
|
|
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
|
|
if [[ $cursor_pos -gt $((visible_count - 1)) ]]; then
|
|
cursor_pos=$((visible_count - 1))
|
|
fi
|
|
if [[ $cursor_pos -lt 0 ]]; then
|
|
cursor_pos=0
|
|
fi
|
|
|
|
printf "\033[H"
|
|
# Calculate total size of selected items for header
|
|
local selected_size=0
|
|
local selected_count=0
|
|
IFS=',' read -r -a sizes <<< "${PURGE_CATEGORY_SIZES:-}"
|
|
for ((i = 0; i < total_items; i++)); do
|
|
if [[ ${selected[i]} == true ]]; then
|
|
selected_size=$((selected_size + ${sizes[i]:-0}))
|
|
((selected_count++))
|
|
fi
|
|
done
|
|
local selected_gb
|
|
selected_gb=$(printf "%.1f" "$(echo "scale=2; $selected_size/1024/1024" | bc)")
|
|
|
|
# Show position indicator if scrolling is needed
|
|
local scroll_indicator=""
|
|
if [[ $total_items -gt $items_per_page ]]; then
|
|
local current_pos=$((top_index + cursor_pos + 1))
|
|
scroll_indicator=" ${GRAY}[${current_pos}/${total_items}]${NC}"
|
|
fi
|
|
|
|
printf "%s\n" "$clear_line"
|
|
printf "%s${PURPLE_BOLD}Select Categories to Clean${NC}%s ${GRAY}- ${selected_gb}GB ($selected_count selected)${NC}\n" "$clear_line" "$scroll_indicator"
|
|
printf "%s\n" "$clear_line"
|
|
|
|
IFS=',' read -r -a recent_flags <<< "${PURGE_RECENT_CATEGORIES:-}"
|
|
|
|
# Calculate visible range
|
|
local end_index=$((top_index + visible_count))
|
|
|
|
# Draw only visible items
|
|
for ((i = top_index; i < end_index; i++)); do
|
|
local checkbox="$ICON_EMPTY"
|
|
[[ ${selected[i]} == true ]] && checkbox="$ICON_SOLID"
|
|
local recent_marker=""
|
|
[[ ${recent_flags[i]:-false} == "true" ]] && recent_marker=" ${GRAY}| Recent${NC}"
|
|
local rel_pos=$((i - top_index))
|
|
if [[ $rel_pos -eq $cursor_pos ]]; then
|
|
printf "%s${CYAN}${ICON_ARROW} %s %s%s${NC}\n" "$clear_line" "$checkbox" "${categories[i]}" "$recent_marker"
|
|
else
|
|
printf "%s %s %s%s\n" "$clear_line" "$checkbox" "${categories[i]}" "$recent_marker"
|
|
fi
|
|
done
|
|
|
|
# Fill empty slots to clear previous content
|
|
local items_shown=$visible_count
|
|
for ((i = items_shown; i < items_per_page; i++)); do
|
|
printf "%s\n" "$clear_line"
|
|
done
|
|
|
|
printf "%s\n" "$clear_line"
|
|
|
|
printf "%s${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space Select | Enter Confirm | A All | I Invert | Q Quit${NC}\n" "$clear_line"
|
|
}
|
|
trap restore_terminal EXIT
|
|
trap handle_interrupt INT TERM
|
|
# Preserve interrupt character for Ctrl-C
|
|
stty -echo -icanon intr ^C 2> /dev/null || true
|
|
hide_cursor
|
|
if [[ -t 1 ]]; then
|
|
clear_screen
|
|
fi
|
|
# Main loop
|
|
while true; do
|
|
draw_menu
|
|
# Read key
|
|
IFS= read -r -s -n1 key || key=""
|
|
case "$key" in
|
|
$'\x1b')
|
|
# Arrow keys or ESC
|
|
# Read next 2 chars with timeout (bash 3.2 needs integer)
|
|
IFS= read -r -s -n1 -t 1 key2 || key2=""
|
|
if [[ "$key2" == "[" ]]; then
|
|
IFS= read -r -s -n1 -t 1 key3 || key3=""
|
|
case "$key3" in
|
|
A) # Up arrow
|
|
if [[ $cursor_pos -gt 0 ]]; then
|
|
((cursor_pos--))
|
|
elif [[ $top_index -gt 0 ]]; then
|
|
((top_index--))
|
|
fi
|
|
;;
|
|
B) # Down arrow
|
|
local absolute_index=$((top_index + cursor_pos))
|
|
local last_index=$((total_items - 1))
|
|
if [[ $absolute_index -lt $last_index ]]; then
|
|
local visible_count=$((total_items - top_index))
|
|
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
|
|
if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then
|
|
((cursor_pos++))
|
|
elif [[ $((top_index + visible_count)) -lt $total_items ]]; then
|
|
((top_index++))
|
|
fi
|
|
fi
|
|
;;
|
|
esac
|
|
else
|
|
# ESC alone (no following chars)
|
|
restore_terminal
|
|
return 1
|
|
fi
|
|
;;
|
|
" ") # Space - toggle current item
|
|
local idx=$((top_index + cursor_pos))
|
|
if [[ ${selected[idx]} == true ]]; then
|
|
selected[idx]=false
|
|
else
|
|
selected[idx]=true
|
|
fi
|
|
;;
|
|
"a" | "A") # Select all
|
|
for ((i = 0; i < total_items; i++)); do
|
|
selected[i]=true
|
|
done
|
|
;;
|
|
"i" | "I") # Invert selection
|
|
for ((i = 0; i < total_items; i++)); do
|
|
if [[ ${selected[i]} == true ]]; then
|
|
selected[i]=false
|
|
else
|
|
selected[i]=true
|
|
fi
|
|
done
|
|
;;
|
|
"q" | "Q" | $'\x03') # Quit or Ctrl-C
|
|
restore_terminal
|
|
return 1
|
|
;;
|
|
"" | $'\n' | $'\r') # Enter - confirm
|
|
# Build result
|
|
PURGE_SELECTION_RESULT=""
|
|
for ((i = 0; i < total_items; i++)); do
|
|
if [[ ${selected[i]} == true ]]; then
|
|
[[ -n "$PURGE_SELECTION_RESULT" ]] && PURGE_SELECTION_RESULT+=","
|
|
PURGE_SELECTION_RESULT+="$i"
|
|
fi
|
|
done
|
|
restore_terminal
|
|
return 0
|
|
;;
|
|
esac
|
|
done
|
|
}
|
|
# Main cleanup function - scans and prompts user to select artifacts to clean
|
|
clean_project_artifacts() {
|
|
local -a all_found_items=()
|
|
local -a safe_to_clean=()
|
|
local -a recently_modified=()
|
|
# Set up cleanup on interrupt
|
|
# Note: Declared without 'local' so cleanup_scan trap can access them
|
|
scan_pids=()
|
|
scan_temps=()
|
|
# shellcheck disable=SC2329
|
|
cleanup_scan() {
|
|
# Kill all background scans
|
|
for pid in "${scan_pids[@]+"${scan_pids[@]}"}"; do
|
|
kill "$pid" 2> /dev/null || true
|
|
done
|
|
# Clean up temp files
|
|
for temp in "${scan_temps[@]+"${scan_temps[@]}"}"; do
|
|
rm -f "$temp" 2> /dev/null || true
|
|
done
|
|
if [[ -t 1 ]]; then
|
|
stop_inline_spinner
|
|
fi
|
|
echo ""
|
|
exit 130
|
|
}
|
|
trap cleanup_scan INT TERM
|
|
# Start parallel scanning of all paths at once
|
|
if [[ -t 1 ]]; then
|
|
start_inline_spinner "Scanning projects..."
|
|
fi
|
|
# Launch all scans in parallel
|
|
for path in "${PURGE_SEARCH_PATHS[@]}"; do
|
|
if [[ -d "$path" ]]; then
|
|
local scan_output
|
|
scan_output=$(mktemp)
|
|
scan_temps+=("$scan_output")
|
|
# Launch scan in background for true parallelism
|
|
scan_purge_targets "$path" "$scan_output" &
|
|
local scan_pid=$!
|
|
scan_pids+=("$scan_pid")
|
|
fi
|
|
done
|
|
# Wait for all scans to complete
|
|
for pid in "${scan_pids[@]+"${scan_pids[@]}"}"; do
|
|
wait "$pid" 2> /dev/null || true
|
|
done
|
|
if [[ -t 1 ]]; then
|
|
stop_inline_spinner
|
|
fi
|
|
# Collect all results
|
|
for scan_output in "${scan_temps[@]+"${scan_temps[@]}"}"; do
|
|
if [[ -f "$scan_output" ]]; then
|
|
while IFS= read -r item; do
|
|
if [[ -n "$item" ]]; then
|
|
all_found_items+=("$item")
|
|
fi
|
|
done < "$scan_output"
|
|
rm -f "$scan_output"
|
|
fi
|
|
done
|
|
# Clean up trap
|
|
trap - INT TERM
|
|
if [[ ${#all_found_items[@]} -eq 0 ]]; then
|
|
echo ""
|
|
echo -e "${GREEN}${ICON_SUCCESS}${NC} Great! No old project artifacts to clean"
|
|
printf '\n'
|
|
return 2 # Special code: nothing to clean
|
|
fi
|
|
# Mark recently modified items (for default selection state)
|
|
for item in "${all_found_items[@]}"; do
|
|
if is_recently_modified "$item"; then
|
|
recently_modified+=("$item")
|
|
fi
|
|
# Add all items to safe_to_clean, let user choose
|
|
safe_to_clean+=("$item")
|
|
done
|
|
# Build menu options - one per artifact
|
|
if [[ -t 1 ]]; then
|
|
start_inline_spinner "Calculating sizes..."
|
|
fi
|
|
local -a menu_options=()
|
|
local -a item_paths=()
|
|
local -a item_sizes=()
|
|
local -a item_recent_flags=()
|
|
# Helper to get project name from path
|
|
# For ~/www/pake/src-tauri/target -> returns "pake"
|
|
# For ~/work/code/MyProject/node_modules -> returns "MyProject"
|
|
# Strategy: Find the nearest ancestor directory containing a project indicator file
|
|
get_project_name() {
|
|
local path="$1"
|
|
local artifact_name
|
|
artifact_name=$(basename "$path")
|
|
|
|
# Start from the parent of the artifact and walk up
|
|
local current_dir
|
|
current_dir=$(dirname "$path")
|
|
|
|
while [[ "$current_dir" != "/" && "$current_dir" != "$HOME" && -n "$current_dir" ]]; do
|
|
# Check if current directory contains any project indicator
|
|
for indicator in "${PROJECT_INDICATORS[@]}"; do
|
|
if [[ -e "$current_dir/$indicator" ]]; then
|
|
# Found a project root, return its name
|
|
basename "$current_dir"
|
|
return 0
|
|
fi
|
|
done
|
|
# Move up one level
|
|
current_dir=$(dirname "$current_dir")
|
|
done
|
|
|
|
# Fallback: try the old logic (first directory under search root)
|
|
local search_roots=()
|
|
if [[ ${#PURGE_SEARCH_PATHS[@]} -gt 0 ]]; then
|
|
search_roots=("${PURGE_SEARCH_PATHS[@]}")
|
|
else
|
|
search_roots=("$HOME/www" "$HOME/dev" "$HOME/Projects")
|
|
fi
|
|
for root in "${search_roots[@]}"; do
|
|
root="${root%/}"
|
|
if [[ -n "$root" && "$path" == "$root/"* ]]; then
|
|
local relative_path="${path#"$root"/}"
|
|
echo "$relative_path" | cut -d'/' -f1
|
|
return 0
|
|
fi
|
|
done
|
|
|
|
# Final fallback: use grandparent directory
|
|
dirname "$(dirname "$path")" | xargs basename
|
|
}
|
|
# Format display with alignment (like app_selector)
|
|
format_purge_display() {
|
|
local project_name="$1"
|
|
local artifact_type="$2"
|
|
local size_str="$3"
|
|
# Terminal width for alignment
|
|
local terminal_width=$(tput cols 2> /dev/null || echo 80)
|
|
local fixed_width=28 # Reserve for type and size
|
|
local available_width=$((terminal_width - fixed_width))
|
|
# Bounds: 24-35 chars for project name
|
|
[[ $available_width -lt 24 ]] && available_width=24
|
|
[[ $available_width -gt 35 ]] && available_width=35
|
|
# Truncate project name if needed
|
|
local truncated_name=$(truncate_by_display_width "$project_name" "$available_width")
|
|
local current_width=$(get_display_width "$truncated_name")
|
|
local char_count=${#truncated_name}
|
|
local padding=$((available_width - current_width))
|
|
local printf_width=$((char_count + padding))
|
|
# Format: "project_name size | artifact_type"
|
|
printf "%-*s %9s | %-13s" "$printf_width" "$truncated_name" "$size_str" "$artifact_type"
|
|
}
|
|
# Build menu options - one line per artifact
|
|
for item in "${safe_to_clean[@]}"; do
|
|
local project_name=$(get_project_name "$item")
|
|
local artifact_type=$(basename "$item")
|
|
local size_kb=$(get_dir_size_kb "$item")
|
|
local size_human=$(bytes_to_human "$((size_kb * 1024))")
|
|
# Check if recent
|
|
local is_recent=false
|
|
for recent_item in "${recently_modified[@]+"${recently_modified[@]}"}"; do
|
|
if [[ "$item" == "$recent_item" ]]; then
|
|
is_recent=true
|
|
break
|
|
fi
|
|
done
|
|
menu_options+=("$(format_purge_display "$project_name" "$artifact_type" "$size_human")")
|
|
item_paths+=("$item")
|
|
item_sizes+=("$size_kb")
|
|
item_recent_flags+=("$is_recent")
|
|
done
|
|
if [[ -t 1 ]]; then
|
|
stop_inline_spinner
|
|
fi
|
|
# Set global vars for selector
|
|
export PURGE_CATEGORY_SIZES=$(
|
|
IFS=,
|
|
echo "${item_sizes[*]}"
|
|
)
|
|
export PURGE_RECENT_CATEGORIES=$(
|
|
IFS=,
|
|
echo "${item_recent_flags[*]}"
|
|
)
|
|
# Interactive selection (only if terminal is available)
|
|
PURGE_SELECTION_RESULT=""
|
|
if [[ -t 0 ]]; then
|
|
if ! select_purge_categories "${menu_options[@]}"; then
|
|
unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT
|
|
return 1
|
|
fi
|
|
else
|
|
# Non-interactive: select all non-recent items
|
|
for ((i = 0; i < ${#menu_options[@]}; i++)); do
|
|
if [[ ${item_recent_flags[i]} != "true" ]]; then
|
|
[[ -n "$PURGE_SELECTION_RESULT" ]] && PURGE_SELECTION_RESULT+=","
|
|
PURGE_SELECTION_RESULT+="$i"
|
|
fi
|
|
done
|
|
fi
|
|
if [[ -z "$PURGE_SELECTION_RESULT" ]]; then
|
|
echo ""
|
|
echo -e "${GRAY}No items selected${NC}"
|
|
printf '\n'
|
|
unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT
|
|
return 0
|
|
fi
|
|
# Clean selected items
|
|
echo ""
|
|
IFS=',' read -r -a selected_indices <<< "$PURGE_SELECTION_RESULT"
|
|
local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole"
|
|
local cleaned_count=0
|
|
for idx in "${selected_indices[@]}"; do
|
|
local item_path="${item_paths[idx]}"
|
|
local artifact_type=$(basename "$item_path")
|
|
local project_name=$(get_project_name "$item_path")
|
|
local size_kb="${item_sizes[idx]}"
|
|
local size_human=$(bytes_to_human "$((size_kb * 1024))")
|
|
# Safety checks
|
|
if [[ -z "$item_path" || "$item_path" == "/" || "$item_path" == "$HOME" || "$item_path" != "$HOME/"* ]]; then
|
|
continue
|
|
fi
|
|
if [[ -t 1 ]]; then
|
|
start_inline_spinner "Cleaning $project_name/$artifact_type..."
|
|
fi
|
|
if [[ -e "$item_path" ]]; then
|
|
safe_remove "$item_path" true
|
|
if [[ ! -e "$item_path" ]]; then
|
|
local current_total=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo "0")
|
|
echo "$((current_total + size_kb))" > "$stats_dir/purge_stats"
|
|
((cleaned_count++))
|
|
fi
|
|
fi
|
|
if [[ -t 1 ]]; then
|
|
stop_inline_spinner
|
|
echo -e "${GREEN}${ICON_SUCCESS}${NC} $project_name - $artifact_type ${GREEN}($size_human)${NC}"
|
|
fi
|
|
done
|
|
# Update count
|
|
echo "$cleaned_count" > "$stats_dir/purge_count"
|
|
unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT
|
|
}
|