diff --git a/bin/uninstall.sh b/bin/uninstall.sh index 3343966..4794a49 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -88,7 +88,13 @@ scan_applications() { fi fi - # Cache miss - show scanning feedback below + # Cache miss - prepare for scanning + local inline_loading=false + if [[ -t 1 && -t 2 ]]; then + inline_loading=true + # Clear screen for inline loading + printf "\033[2J\033[H" >&2 + fi local temp_file temp_file=$(create_temp_file) @@ -97,11 +103,7 @@ scan_applications() { local current_epoch current_epoch=$(date "+%s") - # Spinner for scanning feedback (simple ASCII for compatibility) - local spinner_chars="|/-\\" - local spinner_idx=0 - - # First pass: quickly collect all valid app paths and bundle IDs + # First pass: quickly collect all valid app paths and bundle IDs (NO mdls calls) local -a app_data_tuples=() while IFS= read -r -d '' app_path; do if [[ ! -e "$app_path" ]]; then continue; fi @@ -118,78 +120,19 @@ scan_applications() { continue fi - # Try to get English name from bundle info, fallback to folder name + # Get bundle ID only (fast, no mdls calls in first pass) local bundle_id="unknown" - local display_name="$app_name" if [[ -f "$app_path/Contents/Info.plist" ]]; then bundle_id=$(defaults read "$app_path/Contents/Info.plist" CFBundleIdentifier 2> /dev/null || echo "unknown") - - # Try to get English name from bundle info - 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 - local candidates=() - - # Get all potential names - 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) - - # Check if executable name is generic/technical (should be avoided) - local is_generic_executable=false - if [[ -n "$bundle_executable" ]]; then - case "$bundle_executable" in - "pake" | "Electron" | "electron" | "nwjs" | "node" | "helper" | "main" | "app" | "binary") - is_generic_executable=true - ;; - esac - fi - - # Priority order for name selection: - # 1. App folder name (if ASCII and descriptive) - often the most complete name - if [[ "$app_name" =~ ^[A-Za-z0-9\ ._-]+$ && ${#app_name} -gt 3 ]]; then - candidates+=("$app_name") - fi - - # 2. CFBundleDisplayName (if meaningful and ASCII) - if [[ -n "$bundle_display_name" && "$bundle_display_name" =~ ^[A-Za-z0-9\ ._-]+$ ]]; then - candidates+=("$bundle_display_name") - fi - - # 3. CFBundleName (if meaningful and ASCII) - if [[ -n "$bundle_name" && "$bundle_name" =~ ^[A-Za-z0-9\ ._-]+$ && "$bundle_name" != "$bundle_display_name" ]]; then - candidates+=("$bundle_name") - fi - - # 4. CFBundleExecutable (only if not generic and ASCII) - if [[ -n "$bundle_executable" && "$bundle_executable" =~ ^[A-Za-z0-9._-]+$ && "$is_generic_executable" == false ]]; then - candidates+=("$bundle_executable") - fi - - # 5. Fallback to non-ASCII names if no ASCII found - if [[ ${#candidates[@]} -eq 0 ]]; then - [[ -n "$bundle_display_name" ]] && candidates+=("$bundle_display_name") - [[ -n "$bundle_name" && "$bundle_name" != "$bundle_display_name" ]] && candidates+=("$bundle_name") - candidates+=("$app_name") - fi - - # Select the first (best) candidate - display_name="${candidates[0]:-$app_name}" - - # Apply brand name mapping from common.sh - display_name="$(get_brand_name "$display_name")" fi # Skip system critical apps (input methods, system components) - # Note: Paid apps like CleanMyMac, 1Password are NOT protected here - users can uninstall them if should_protect_from_uninstall "$bundle_id"; then continue fi - # Store tuple: app_path|app_name|bundle_id|display_name - app_data_tuples+=("${app_path}|${app_name}|${bundle_id}|${display_name}") + # Store tuple: app_path|app_name|bundle_id (display_name will be resolved in parallel later) + app_data_tuples+=("${app_path}|${app_name}|${bundle_id}") done < <( # Scan both system and user application directories # Using maxdepth 3 to find apps in subdirectories (e.g., Adobe apps in /Applications/Adobe X/) @@ -209,11 +152,7 @@ scan_applications() { max_parallel=32 fi local pids=() - local inline_loading=false - if [[ "${MOLE_INLINE_LOADING:-}" == "1" || "${MOLE_INLINE_LOADING:-}" == "true" ]]; then - inline_loading=true - printf "\033[H" >&2 # Position cursor at top of screen - fi + # inline_loading variable already set above (line ~92) # Process app metadata extraction function process_app_metadata() { @@ -221,7 +160,35 @@ scan_applications() { local output_file="$2" local current_epoch="$3" - IFS='|' read -r app_path app_name bundle_id display_name <<< "$app_data_tuple" + IFS='|' read -r app_path app_name bundle_id <<< "$app_data_tuple" + + # Get localized display name (moved from first pass for better performance) + 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 + 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 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 + + 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 + display_name="$bundle_display_name" + elif [[ -n "$bundle_name" && "$bundle_name" != "(null)" ]]; then + display_name="$bundle_name" + fi + fi # Parallel size calculation local app_size="N/A" @@ -293,9 +260,9 @@ scan_applications() { local completed=$(cat "$progress_file" 2> /dev/null || echo 0) local c="${spinner_chars:$((i % 4)):1}" if [[ $inline_loading == true ]]; then - printf "\033[H\033[2K%s Scanning applications... %d/%d" "$c" "$completed" "$total_apps" >&2 + printf "\033[H\033[2K%s Scanning applications... %d/%d\n" "$c" "$completed" "$total_apps" >&2 else - echo -ne "\r\033[K%s Scanning applications... %d/%d" "$c" "$completed" "$total_apps" >&2 + printf "\r\033[K%s Scanning applications... %d/%d" "$c" "$completed" "$total_apps" >&2 fi ((i++)) sleep 0.1 2> /dev/null || sleep 1 @@ -346,12 +313,30 @@ scan_applications() { fi # Sort by last used (oldest first) and cache the result + # Show brief processing message for large app lists + if [[ $total_apps -gt 50 ]]; then + if [[ $inline_loading == true ]]; then + printf "\033[H\033[2KProcessing %d applications...\n" "$total_apps" >&2 + else + printf "\rProcessing %d applications... " "$total_apps" >&2 + fi + fi + sort -t'|' -k1,1n "$temp_file" > "${temp_file}.sorted" || { rm -f "$temp_file" return 1 } rm -f "$temp_file" + # Clear processing message + if [[ $total_apps -gt 50 ]]; then + if [[ $inline_loading == true ]]; then + printf "\033[H\033[2K" >&2 + else + printf "\r\033[K" >&2 + fi + fi + # Save to cache (simplified - no metadata) cp "${temp_file}.sorted" "$cache_file" 2> /dev/null || true @@ -555,18 +540,22 @@ main() { # Show selected apps with clean alignment echo -e "${BLUE}${ICON_CONFIRM}${NC} Selected ${selection_count} app(s):" local -a summary_rows=() - local max_name_width=0 + local max_name_display_width=0 local max_size_width=0 local name_trunc_limit=30 for selected_app in "${selected_apps[@]}"; do IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb <<< "$selected_app" - local display_name="$app_name" - if [[ ${#display_name} -gt $name_trunc_limit ]]; then - display_name="${display_name:0:$((name_trunc_limit - 3))}..." - fi - [[ ${#display_name} -gt $max_name_width ]] && max_name_width=${#display_name} + # Truncate by display width if needed + local display_name + display_name=$(truncate_by_display_width "$app_name" "$name_trunc_limit") + + # Get actual display width + local current_width + current_width=$(get_display_width "$display_name") + + [[ $current_width -gt $max_name_display_width ]] && max_name_display_width=$current_width local size_display="$size" if [[ -z "$size_display" || "$size_display" == "0" || "$size_display" == "N/A" ]]; then @@ -580,13 +569,20 @@ main() { summary_rows+=("$display_name|$size_display|$last_display") done - ((max_name_width < 16)) && max_name_width=16 + ((max_name_display_width < 16)) && max_name_display_width=16 ((max_size_width < 5)) && max_size_width=5 local index=1 for row in "${summary_rows[@]}"; do IFS='|' read -r name_cell size_cell last_cell <<< "$row" - printf "%d. %-*s %*s | Last: %s\n" "$index" "$max_name_width" "$name_cell" "$max_size_width" "$size_cell" "$last_cell" + # Calculate printf width based on actual display width + local name_display_width + name_display_width=$(get_display_width "$name_cell") + local name_char_count=${#name_cell} + local padding_needed=$((max_name_display_width - name_display_width)) + local printf_name_width=$((name_char_count + padding_needed)) + + printf "%d. %-*s %*s | Last: %s\n" "$index" "$printf_name_width" "$name_cell" "$max_size_width" "$size_cell" "$last_cell" ((index++)) done diff --git a/bin/uninstall_lib.sh b/bin/uninstall_lib.sh index 998ba1e..6ec88f2 100755 --- a/bin/uninstall_lib.sh +++ b/bin/uninstall_lib.sh @@ -88,7 +88,13 @@ scan_applications() { fi fi - # Cache miss - show scanning feedback below + # Cache miss - prepare for scanning + local inline_loading=false + if [[ -t 1 && -t 2 ]]; then + inline_loading=true + # Clear screen for inline loading + printf "\033[2J\033[H" >&2 + fi local temp_file temp_file=$(create_temp_file) @@ -97,11 +103,7 @@ scan_applications() { local current_epoch current_epoch=$(date "+%s") - # Spinner for scanning feedback (simple ASCII for compatibility) - local spinner_chars="|/-\\" - local spinner_idx=0 - - # First pass: quickly collect all valid app paths and bundle IDs + # First pass: quickly collect all valid app paths and bundle IDs (NO mdls calls) local -a app_data_tuples=() while IFS= read -r -d '' app_path; do if [[ ! -e "$app_path" ]]; then continue; fi @@ -118,78 +120,19 @@ scan_applications() { continue fi - # Try to get English name from bundle info, fallback to folder name + # Get bundle ID only (fast, no mdls calls in first pass) local bundle_id="unknown" - local display_name="$app_name" if [[ -f "$app_path/Contents/Info.plist" ]]; then bundle_id=$(defaults read "$app_path/Contents/Info.plist" CFBundleIdentifier 2> /dev/null || echo "unknown") - - # Try to get English name from bundle info - 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 - local candidates=() - - # Get all potential names - 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) - - # Check if executable name is generic/technical (should be avoided) - local is_generic_executable=false - if [[ -n "$bundle_executable" ]]; then - case "$bundle_executable" in - "pake" | "Electron" | "electron" | "nwjs" | "node" | "helper" | "main" | "app" | "binary") - is_generic_executable=true - ;; - esac - fi - - # Priority order for name selection: - # 1. App folder name (if ASCII and descriptive) - often the most complete name - if [[ "$app_name" =~ ^[A-Za-z0-9\ ._-]+$ && ${#app_name} -gt 3 ]]; then - candidates+=("$app_name") - fi - - # 2. CFBundleDisplayName (if meaningful and ASCII) - if [[ -n "$bundle_display_name" && "$bundle_display_name" =~ ^[A-Za-z0-9\ ._-]+$ ]]; then - candidates+=("$bundle_display_name") - fi - - # 3. CFBundleName (if meaningful and ASCII) - if [[ -n "$bundle_name" && "$bundle_name" =~ ^[A-Za-z0-9\ ._-]+$ && "$bundle_name" != "$bundle_display_name" ]]; then - candidates+=("$bundle_name") - fi - - # 4. CFBundleExecutable (only if not generic and ASCII) - if [[ -n "$bundle_executable" && "$bundle_executable" =~ ^[A-Za-z0-9._-]+$ && "$is_generic_executable" == false ]]; then - candidates+=("$bundle_executable") - fi - - # 5. Fallback to non-ASCII names if no ASCII found - if [[ ${#candidates[@]} -eq 0 ]]; then - [[ -n "$bundle_display_name" ]] && candidates+=("$bundle_display_name") - [[ -n "$bundle_name" && "$bundle_name" != "$bundle_display_name" ]] && candidates+=("$bundle_name") - candidates+=("$app_name") - fi - - # Select the first (best) candidate - display_name="${candidates[0]:-$app_name}" - - # Apply brand name mapping from common.sh - display_name="$(get_brand_name "$display_name")" fi # Skip system critical apps (input methods, system components) - # Note: Paid apps like CleanMyMac, 1Password are NOT protected here - users can uninstall them if should_protect_from_uninstall "$bundle_id"; then continue fi - # Store tuple: app_path|app_name|bundle_id|display_name - app_data_tuples+=("${app_path}|${app_name}|${bundle_id}|${display_name}") + # Store tuple: app_path|app_name|bundle_id (display_name will be resolved in parallel later) + app_data_tuples+=("${app_path}|${app_name}|${bundle_id}") done < <( # Scan both system and user application directories # Using maxdepth 3 to find apps in subdirectories (e.g., Adobe apps in /Applications/Adobe X/) @@ -209,11 +152,7 @@ scan_applications() { max_parallel=32 fi local pids=() - local inline_loading=false - if [[ "${MOLE_INLINE_LOADING:-}" == "1" || "${MOLE_INLINE_LOADING:-}" == "true" ]]; then - inline_loading=true - printf "\033[H" >&2 # Position cursor at top of screen - fi + # inline_loading variable already set above (line ~92) # Process app metadata extraction function process_app_metadata() { @@ -221,7 +160,35 @@ scan_applications() { local output_file="$2" local current_epoch="$3" - IFS='|' read -r app_path app_name bundle_id display_name <<< "$app_data_tuple" + IFS='|' read -r app_path app_name bundle_id <<< "$app_data_tuple" + + # Get localized display name (moved from first pass for better performance) + 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 + 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 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 + + 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 + display_name="$bundle_display_name" + elif [[ -n "$bundle_name" && "$bundle_name" != "(null)" ]]; then + display_name="$bundle_name" + fi + fi # Parallel size calculation local app_size="N/A" @@ -293,9 +260,9 @@ scan_applications() { local completed=$(cat "$progress_file" 2> /dev/null || echo 0) local c="${spinner_chars:$((i % 4)):1}" if [[ $inline_loading == true ]]; then - printf "\033[H\033[2K%s Scanning applications... %d/%d" "$c" "$completed" "$total_apps" >&2 + printf "\033[H\033[2K%s Scanning applications... %d/%d\n" "$c" "$completed" "$total_apps" >&2 else - echo -ne "\r\033[K%s Scanning applications... %d/%d" "$c" "$completed" "$total_apps" >&2 + printf "\r\033[K%s Scanning applications... %d/%d" "$c" "$completed" "$total_apps" >&2 fi ((i++)) sleep 0.1 2> /dev/null || sleep 1 @@ -346,12 +313,30 @@ scan_applications() { fi # Sort by last used (oldest first) and cache the result + # Show brief processing message for large app lists + if [[ $total_apps -gt 50 ]]; then + if [[ $inline_loading == true ]]; then + printf "\033[H\033[2KProcessing %d applications...\n" "$total_apps" >&2 + else + printf "\rProcessing %d applications... " "$total_apps" >&2 + fi + fi + sort -t'|' -k1,1n "$temp_file" > "${temp_file}.sorted" || { rm -f "$temp_file" return 1 } rm -f "$temp_file" + # Clear processing message + if [[ $total_apps -gt 50 ]]; then + if [[ $inline_loading == true ]]; then + printf "\033[H\033[2K" >&2 + else + printf "\r\033[K" >&2 + fi + fi + # Save to cache (simplified - no metadata) cp "${temp_file}.sorted" "$cache_file" 2> /dev/null || true diff --git a/lib/ui/app_selector.sh b/lib/ui/app_selector.sh index 7454b6b..7d402d4 100755 --- a/lib/ui/app_selector.sh +++ b/lib/ui/app_selector.sh @@ -3,6 +3,8 @@ set -euo pipefail +# Note: get_display_width() is now defined in lib/core/ui.sh + # Format app info for display format_app_display() { local display_name="$1" size="$2" last_used="$3" @@ -20,18 +22,26 @@ format_app_display() { local fixed_width=28 local available_width=$((terminal_width - fixed_width)) - # Set reasonable bounds for name width: 24-35 chars + # Set reasonable bounds for name width: 24-35 display width [[ $available_width -lt 24 ]] && available_width=24 [[ $available_width -gt 35 ]] && available_width=35 - # Truncate long names if needed - local truncated_name="$display_name" - if [[ ${#display_name} -gt $available_width ]]; then - truncated_name="${display_name:0:$((available_width - 3))}..." - fi + # Truncate long names if needed (based on display width, not char count) + local truncated_name + truncated_name=$(truncate_by_display_width "$display_name" "$available_width") - # Use dynamic column width for better readability - printf "%-*s %9s | %s" "$available_width" "$truncated_name" "$size_str" "$compact_last_used" + # Get actual display width after truncation + local current_display_width + current_display_width=$(get_display_width "$truncated_name") + + # Calculate padding needed + # Formula: char_count + (available_width - display_width) = padding to add + local char_count=${#truncated_name} + local padding_needed=$((available_width - current_display_width)) + local printf_width=$((char_count + padding_needed)) + + # Use dynamic column width with corrected padding + printf "%-*s %9s | %s" "$printf_width" "$truncated_name" "$size_str" "$compact_last_used" } # Global variable to store selection result (bash 3.2 compatible) @@ -46,6 +56,14 @@ select_apps_for_uninstall() { fi # Build menu options + # Show loading for large lists (formatting can be slow due to width calculations) + local app_count=${#apps_data[@]} + if [[ $app_count -gt 30 ]]; then + if [[ -t 2 ]]; then + printf "\rPreparing %d applications... " "$app_count" >&2 + fi + fi + local -a menu_options=() # Prepare metadata (comma-separated) for sorting/filtering inside the menu local epochs_csv="" @@ -66,6 +84,13 @@ select_apps_for_uninstall() { ((idx++)) done + # Clear loading message + if [[ $app_count -gt 30 ]]; then + if [[ -t 2 ]]; then + printf "\r\033[K" >&2 + fi + fi + # Expose metadata for the paginated menu (optional inputs) # - MOLE_MENU_META_EPOCHS: numeric last_used_epoch per item # - MOLE_MENU_META_SIZEKB: numeric size in KB per item