diff --git a/bin/analyze-go b/bin/analyze-go index e97b3b1..34f714d 100755 Binary files a/bin/analyze-go and b/bin/analyze-go differ diff --git a/bin/status-go b/bin/status-go index 5cff9cb..3eeece1 100755 Binary files a/bin/status-go and b/bin/status-go differ diff --git a/bin/uninstall.sh b/bin/uninstall.sh index d32378d..8c74d78 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -67,10 +67,11 @@ format_last_used_summary() { # Scan applications and collect information scan_applications() { - # Simplified cache: only check timestamp (24h TTL) + # Application scan with intelligent caching (24h TTL) + # This speeds up repeated scans significantly by caching app metadata local cache_dir="$HOME/.cache/mole" local cache_file="$cache_dir/app_scan_cache" - local cache_ttl=86400 + local cache_ttl=86400 # 24 hours local force_rescan="${1:-false}" mkdir -p "$cache_dir" 2> /dev/null @@ -78,30 +79,34 @@ scan_applications() { # Check if cache exists and is fresh if [[ $force_rescan == false && -f "$cache_file" ]]; then local cache_age=$(($(date +%s) - $(get_file_mtime "$cache_file"))) - [[ $cache_age -eq $(date +%s) ]] && cache_age=86401 + [[ $cache_age -eq $(date +%s) ]] && cache_age=86401 # Handle mtime read failure if [[ $cache_age -lt $cache_ttl ]]; then + # Cache hit - show brief feedback and return cached results if [[ -t 2 ]]; then echo -e "${GREEN}Loading from cache...${NC}" >&2 - sleep 0.3 + sleep 0.3 # Brief pause so user sees the message fi echo "$cache_file" return 0 fi fi + # Cache miss - perform full scan local inline_loading=false if [[ -t 1 && -t 2 ]]; then inline_loading=true - printf "\033[2J\033[H" >&2 + printf "\033[2J\033[H" >&2 # Clear screen for inline loading fi local temp_file temp_file=$(create_temp_file) + # Pre-cache current epoch to avoid repeated date calls local current_epoch current_epoch=$(date "+%s") # First pass: quickly collect all valid app paths and bundle IDs + # This pass does NOT call mdls (slow) - only reads plists (fast) local -a app_data_tuples=() local -a app_dirs=( "/Applications" @@ -118,35 +123,42 @@ scan_applications() { app_name=$(basename "$app_path" .app) # Skip nested apps (e.g. inside Wrapper/ or Frameworks/ of another app) + # Check if parent path component ends in .app (e.g. /Foo.app/Bar.app or /Foo.app/Contents/Bar.app) + # This prevents false positives like /Old.apps/Target.app local parent_dir parent_dir=$(dirname "$app_path") if [[ "$parent_dir" == *".app" || "$parent_dir" == *".app/"* ]]; then continue fi + # Get bundle ID (fast plist read, no mdls call yet) local bundle_id="unknown" if [[ -f "$app_path/Contents/Info.plist" ]]; then bundle_id=$(defaults read "$app_path/Contents/Info.plist" CFBundleIdentifier 2> /dev/null || echo "unknown") fi - # Skip system critical apps + # Skip system critical apps (input methods, system components, etc.) if should_protect_from_uninstall "$bundle_id"; then continue fi + # Store tuple: app_path|app_name|bundle_id + # Display name and metadata will be resolved in parallel later (second pass) app_data_tuples+=("${app_path}|${app_name}|${bundle_id}") done < <(command find "$app_dir" -name "*.app" -maxdepth 3 -print0 2> /dev/null) done - # Second pass: process each app with parallel size calculation + # Second pass: process each app with parallel metadata extraction + # This pass calls mdls (slow) and calculates sizes, but does so in parallel local app_count=0 local total_apps=${#app_data_tuples[@]} + # Bound parallelism - for metadata queries, can go higher since it's mostly waiting local max_parallel max_parallel=$(get_optimal_parallel_jobs "io") if [[ $max_parallel -lt 8 ]]; then - max_parallel=8 + max_parallel=8 # At least 8 for good performance elif [[ $max_parallel -gt 32 ]]; then - max_parallel=32 + max_parallel=32 # Cap at 32 to avoid too many processes fi local pids=() @@ -157,18 +169,25 @@ scan_applications() { IFS='|' read -r app_path app_name bundle_id <<< "$app_data_tuple" - # Get localized display name + # Get localized display name (moved from first pass for better performance) + # Priority order for name selection (prefer localized names): + # 1. System metadata display name (kMDItemDisplayName) - respects system language + # 2. CFBundleDisplayName - usually localized + # 3. CFBundleName - fallback + # 4. App folder name - last resort local display_name="$app_name" if [[ -f "$app_path/Contents/Info.plist" ]]; then + # Try to get localized name from system metadata (best for i18n) local md_display_name md_display_name=$(run_with_timeout 0.05 mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "") + # Get bundle names from plist local bundle_display_name 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) - # Priority order: MDItemDisplayName > CFBundleDisplayName > CFBundleName + # Select best available name if [[ -n "$md_display_name" && "$md_display_name" != "(null)" && "$md_display_name" != "$app_name" ]]; then display_name="$md_display_name" elif [[ -n "$bundle_display_name" && "$bundle_display_name" != "(null)" ]]; then @@ -178,19 +197,21 @@ scan_applications() { fi fi - # Parallel size calculation + # Calculate app size (in parallel for performance) local app_size="N/A" local app_size_kb="0" if [[ -d "$app_path" ]]; then + # Get size in KB, then format for display app_size_kb=$(get_path_size_kb "$app_path") app_size=$(bytes_to_human "$((app_size_kb * 1024))") fi - # Get last used date + # Get last used date with fallback strategy local last_used="Never" local last_used_epoch=0 if [[ -d "$app_path" ]]; then + # Try mdls first with short timeout (0.05s) for accuracy, fallback to mtime for speed local metadata_date metadata_date=$(run_with_timeout 0.05 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "") @@ -198,6 +219,7 @@ scan_applications() { last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0") fi + # Fallback if mdls failed or returned nothing if [[ "$last_used_epoch" -eq 0 ]]; then last_used_epoch=$(get_file_mtime "$app_path") fi diff --git a/cmd/analyze/main.go b/cmd/analyze/main.go index 840a379..7c6eebf 100644 --- a/cmd/analyze/main.go +++ b/cmd/analyze/main.go @@ -111,8 +111,8 @@ type model struct { overviewScanningSet map[string]bool // Track which paths are currently being scanned width int // Terminal width height int // Terminal height - multiSelected map[int]bool // Track multi-selected items by index - largeMultiSelected map[int]bool // Track multi-selected large files by index + multiSelected map[string]bool // Track multi-selected items by path (safer than index) + largeMultiSelected map[string]bool // Track multi-selected large files by path (safer than index) } func (m model) inOverviewMode() bool { @@ -179,8 +179,8 @@ func newModel(path string, isOverview bool) model { overviewCurrentPath: &overviewCurrentPath, overviewSizeCache: make(map[string]int64), overviewScanningSet: make(map[string]bool), - multiSelected: make(map[int]bool), - largeMultiSelected: make(map[int]bool), + multiSelected: make(map[string]bool), + largeMultiSelected: make(map[string]bool), } // In overview mode, create shortcut entries @@ -397,8 +397,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.done { m.deleting = false // Clear multi-selection after delete - m.multiSelected = make(map[int]bool) - m.largeMultiSelected = make(map[int]bool) + m.multiSelected = make(map[string]bool) + m.largeMultiSelected = make(map[string]bool) if msg.err != nil { m.status = fmt.Sprintf("Failed to delete: %v", msg.err) } else { @@ -535,23 +535,20 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.deleteCount = &deleteCount // Collect paths to delete (multi-select or single) + // Using paths instead of indices is safer - avoids deleting wrong files if list changes var pathsToDelete []string if m.showLargeFiles { if len(m.largeMultiSelected) > 0 { - for idx := range m.largeMultiSelected { - if idx < len(m.largeFiles) { - pathsToDelete = append(pathsToDelete, m.largeFiles[idx].Path) - } + for path := range m.largeMultiSelected { + pathsToDelete = append(pathsToDelete, path) } } else if m.deleteTarget != nil { pathsToDelete = append(pathsToDelete, m.deleteTarget.Path) } } else { if len(m.multiSelected) > 0 { - for idx := range m.multiSelected { - if idx < len(m.entries) { - pathsToDelete = append(pathsToDelete, m.entries[idx].Path) - } + for path := range m.multiSelected { + pathsToDelete = append(pathsToDelete, path) } } else if m.deleteTarget != nil { pathsToDelete = append(pathsToDelete, m.deleteTarget.Path) @@ -682,8 +679,8 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "r": // Clear multi-selection on refresh - m.multiSelected = make(map[int]bool) - m.largeMultiSelected = make(map[int]bool) + m.multiSelected = make(map[string]bool) + m.largeMultiSelected = make(map[string]bool) if m.inOverviewMode() { // In overview mode, clear cache and re-scan known entries @@ -721,29 +718,32 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.showLargeFiles { m.largeSelected = 0 m.largeOffset = 0 - m.largeMultiSelected = make(map[int]bool) + m.largeMultiSelected = make(map[string]bool) } else { - m.multiSelected = make(map[int]bool) + m.multiSelected = make(map[string]bool) } // Reset status when switching views m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) } case "o": // Open selected entries (multi-select aware) + // Limit batch operations to prevent system resource exhaustion + const maxBatchOpen = 20 if m.showLargeFiles { if len(m.largeFiles) > 0 { // Check for multi-selection first if len(m.largeMultiSelected) > 0 { count := len(m.largeMultiSelected) - for idx := range m.largeMultiSelected { - if idx < len(m.largeFiles) { - path := m.largeFiles[idx].Path - go func(p string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", p).Run() - }(path) - } + if count > maxBatchOpen { + m.status = fmt.Sprintf("Too many items to open (max %d, selected %d)", maxBatchOpen, count) + return m, nil + } + for path := range m.largeMultiSelected { + go func(p string) { + ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) + defer cancel() + _ = exec.CommandContext(ctx, "open", p).Run() + }(path) } m.status = fmt.Sprintf("Opening %d items...", count) } else { @@ -760,15 +760,16 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Check for multi-selection first if len(m.multiSelected) > 0 { count := len(m.multiSelected) - for idx := range m.multiSelected { - if idx < len(m.entries) { - path := m.entries[idx].Path - go func(p string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", p).Run() - }(path) - } + if count > maxBatchOpen { + m.status = fmt.Sprintf("Too many items to open (max %d, selected %d)", maxBatchOpen, count) + return m, nil + } + for path := range m.multiSelected { + go func(p string) { + ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) + defer cancel() + _ = exec.CommandContext(ctx, "open", p).Run() + }(path) } m.status = fmt.Sprintf("Opening %d items...", count) } else { @@ -783,20 +784,23 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case "f", "F": // Reveal selected entries in Finder (multi-select aware) + // Limit batch operations to prevent system resource exhaustion + const maxBatchReveal = 20 if m.showLargeFiles { if len(m.largeFiles) > 0 { // Check for multi-selection first if len(m.largeMultiSelected) > 0 { count := len(m.largeMultiSelected) - for idx := range m.largeMultiSelected { - if idx < len(m.largeFiles) { - path := m.largeFiles[idx].Path - go func(p string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", "-R", p).Run() - }(path) - } + if count > maxBatchReveal { + m.status = fmt.Sprintf("Too many items to reveal (max %d, selected %d)", maxBatchReveal, count) + return m, nil + } + for path := range m.largeMultiSelected { + go func(p string) { + ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) + defer cancel() + _ = exec.CommandContext(ctx, "open", "-R", p).Run() + }(path) } m.status = fmt.Sprintf("Showing %d items in Finder...", count) } else { @@ -813,15 +817,16 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Check for multi-selection first if len(m.multiSelected) > 0 { count := len(m.multiSelected) - for idx := range m.multiSelected { - if idx < len(m.entries) { - path := m.entries[idx].Path - go func(p string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", "-R", p).Run() - }(path) - } + if count > maxBatchReveal { + m.status = fmt.Sprintf("Too many items to reveal (max %d, selected %d)", maxBatchReveal, count) + return m, nil + } + for path := range m.multiSelected { + go func(p string) { + ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) + defer cancel() + _ = exec.CommandContext(ctx, "open", "-R", p).Run() + }(path) } m.status = fmt.Sprintf("Showing %d items in Finder...", count) } else { @@ -836,23 +841,29 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case " ": // Toggle multi-select with spacebar + // Using paths as keys (instead of indices) is safer and more maintainable if m.showLargeFiles { - if len(m.largeFiles) > 0 { + if len(m.largeFiles) > 0 && m.largeSelected < len(m.largeFiles) { if m.largeMultiSelected == nil { - m.largeMultiSelected = make(map[int]bool) + m.largeMultiSelected = make(map[string]bool) } - if m.largeMultiSelected[m.largeSelected] { - delete(m.largeMultiSelected, m.largeSelected) + selectedPath := m.largeFiles[m.largeSelected].Path + if m.largeMultiSelected[selectedPath] { + delete(m.largeMultiSelected, selectedPath) } else { - m.largeMultiSelected[m.largeSelected] = true + m.largeMultiSelected[selectedPath] = true } - // Update status to show selection count + // Update status to show selection count and total size count := len(m.largeMultiSelected) if count > 0 { var totalSize int64 - for idx := range m.largeMultiSelected { - if idx < len(m.largeFiles) { - totalSize += m.largeFiles[idx].Size + // Calculate total size by looking up each selected path + for path := range m.largeMultiSelected { + for _, file := range m.largeFiles { + if file.Path == path { + totalSize += file.Size + break + } } } m.status = fmt.Sprintf("%d selected (%s)", count, humanizeBytes(totalSize)) @@ -860,22 +871,27 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) } } - } else if len(m.entries) > 0 && !m.inOverviewMode() { + } else if len(m.entries) > 0 && !m.inOverviewMode() && m.selected < len(m.entries) { if m.multiSelected == nil { - m.multiSelected = make(map[int]bool) + m.multiSelected = make(map[string]bool) } - if m.multiSelected[m.selected] { - delete(m.multiSelected, m.selected) + selectedPath := m.entries[m.selected].Path + if m.multiSelected[selectedPath] { + delete(m.multiSelected, selectedPath) } else { - m.multiSelected[m.selected] = true + m.multiSelected[selectedPath] = true } - // Update status to show selection count + // Update status to show selection count and total size count := len(m.multiSelected) if count > 0 { var totalSize int64 - for idx := range m.multiSelected { - if idx < len(m.entries) { - totalSize += m.entries[idx].Size + // Calculate total size by looking up each selected path + for path := range m.multiSelected { + for _, entry := range m.entries { + if entry.Path == path { + totalSize += entry.Size + break + } } } m.status = fmt.Sprintf("%d selected (%s)", count, humanizeBytes(totalSize)) @@ -891,19 +907,22 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if len(m.largeMultiSelected) > 0 { m.deleteConfirm = true // Set deleteTarget to first selected for display purposes - for idx := range m.largeMultiSelected { - if idx < len(m.largeFiles) { - selected := m.largeFiles[idx] - m.deleteTarget = &dirEntry{ - Name: selected.Name, - Path: selected.Path, - Size: selected.Size, - IsDir: false, + for path := range m.largeMultiSelected { + // Find the file entry by path + for _, file := range m.largeFiles { + if file.Path == path { + m.deleteTarget = &dirEntry{ + Name: file.Name, + Path: file.Path, + Size: file.Size, + IsDir: false, + } + break } - break } + break // Only need first one for display } - } else { + } else if m.largeSelected < len(m.largeFiles) { selected := m.largeFiles[m.largeSelected] m.deleteConfirm = true m.deleteTarget = &dirEntry{ @@ -919,13 +938,17 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if len(m.multiSelected) > 0 { m.deleteConfirm = true // Set deleteTarget to first selected for display purposes - for idx := range m.multiSelected { - if idx < len(m.entries) { - m.deleteTarget = &m.entries[idx] - break + for path := range m.multiSelected { + // Find the entry by path + for i := range m.entries { + if m.entries[i].Path == path { + m.deleteTarget = &m.entries[i] + break + } } + break // Only need first one for display } - } else { + } else if m.selected < len(m.entries) { selected := m.entries[m.selected] m.deleteConfirm = true m.deleteTarget = &selected @@ -972,8 +995,8 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) { m.scanning = true m.isOverview = false // Clear multi-selection when entering new directory - m.multiSelected = make(map[int]bool) - m.largeMultiSelected = make(map[int]bool) + m.multiSelected = make(map[string]bool) + m.largeMultiSelected = make(map[string]bool) // Reset scan counters for new scan atomic.StoreInt64(m.filesScanned, 0) diff --git a/cmd/analyze/view.go b/cmd/analyze/view.go index f346550..bd5ddb5 100644 --- a/cmd/analyze/view.go +++ b/cmd/analyze/view.go @@ -130,8 +130,8 @@ func (m model) View() string { sizeColor := colorGray numColor := "" - // Check if this item is multi-selected - isMultiSelected := m.largeMultiSelected != nil && m.largeMultiSelected[idx] + // Check if this item is multi-selected (by path, not index) + isMultiSelected := m.largeMultiSelected != nil && m.largeMultiSelected[file.Path] selectIcon := "ā—‹" if isMultiSelected { selectIcon = fmt.Sprintf("%sā—%s", colorGreen, colorReset) @@ -289,8 +289,8 @@ func (m model) View() string { sizeColor = colorGray } - // Check if this item is multi-selected - isMultiSelected := m.multiSelected != nil && m.multiSelected[idx] + // Check if this item is multi-selected (by path, not index) + isMultiSelected := m.multiSelected != nil && m.multiSelected[entry.Path] selectIcon := "ā—‹" nameColor := "" if isMultiSelected { @@ -386,16 +386,24 @@ func (m model) View() string { var totalDeleteSize int64 if m.showLargeFiles && len(m.largeMultiSelected) > 0 { deleteCount = len(m.largeMultiSelected) - for idx := range m.largeMultiSelected { - if idx < len(m.largeFiles) { - totalDeleteSize += m.largeFiles[idx].Size + // Calculate total size by looking up each selected path + for path := range m.largeMultiSelected { + for _, file := range m.largeFiles { + if file.Path == path { + totalDeleteSize += file.Size + break + } } } } else if !m.showLargeFiles && len(m.multiSelected) > 0 { deleteCount = len(m.multiSelected) - for idx := range m.multiSelected { - if idx < len(m.entries) { - totalDeleteSize += m.entries[idx].Size + // Calculate total size by looking up each selected path + for path := range m.multiSelected { + for _, entry := range m.entries { + if entry.Path == path { + totalDeleteSize += entry.Size + break + } } } } diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index 3de941a..5541192 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -486,28 +486,6 @@ should_protect_path() { local path="$1" [[ -z "$path" ]] && return 1 - # 0. Check custom protected paths first - local custom_config="$HOME/.config/mole/protected_paths" - if [[ -f "$custom_config" ]]; then - while IFS= read -r protected_path; do - # Trim whitespace - protected_path="${protected_path#"${protected_path%%[![:space:]]*}"}" - protected_path="${protected_path%"${protected_path##*[![:space:]]}"}" - - # Skip empty lines and comments - [[ -z "$protected_path" || "$protected_path" =~ ^# ]] && continue - - # Expand ~ to $HOME in protected path - protected_path="${protected_path/#\~/$HOME}" - - # Check if path starts with protected path (prefix match) - # This protects both the directory and everything under it - if [[ "$path" == "$protected_path" || "$path" == "$protected_path"/* ]]; then - return 0 - fi - done < "$custom_config" - fi - local path_lower path_lower=$(echo "$path" | tr '[:upper:]' '[:lower:]') diff --git a/lib/manage/whitelist.sh b/lib/manage/whitelist.sh index 9b760ff..da8abe2 100755 --- a/lib/manage/whitelist.sh +++ b/lib/manage/whitelist.sh @@ -41,7 +41,7 @@ save_whitelist_patterns() { header_text="# Mole Optimization Whitelist - These checks will be skipped during optimization" else config_file="$WHITELIST_CONFIG_CLEAN" - header_text="# Mole Whitelist - Protected paths won't be deleted\n# Default protections: Playwright browsers, HuggingFace models, Maven repo, Ollama models, Surge Mac, R renv, Finder metadata\n#\n# Add one pattern per line to keep items safe.\n#\n# You can also add custom paths to protect (e.g., ~/important-project, /opt/myapp):\n# ~/my-project\n# ~/.config/important-app" + header_text="# Mole Whitelist - Protected paths won't be deleted\n# Default protections: Playwright browsers, HuggingFace models, Maven repo, Ollama models, Surge Mac, R renv, Finder metadata\n# Add one pattern per line to keep items safe." fi mkdir -p "$(dirname "$config_file")"