diff --git a/lib/ui/menu_paginated.sh b/lib/ui/menu_paginated.sh index 26e8661..18c1503 100755 --- a/lib/ui/menu_paginated.sh +++ b/lib/ui/menu_paginated.sh @@ -87,13 +87,8 @@ paginated_multi_select() { local items_per_page=$(_pm_calculate_items_per_page) local cursor_pos=0 local top_index=0 - local filter_query="" - local filter_mode="false" # filter mode toggle local sort_mode="${MOLE_MENU_SORT_MODE:-${MOLE_MENU_SORT_DEFAULT:-date}}" # date|name|size local sort_reverse="${MOLE_MENU_SORT_REVERSE:-false}" - # Live query vs applied query - local applied_query="" - local searching="false" # Metadata (optional) # epochs[i] -> last_used_epoch (numeric) for item i @@ -124,36 +119,6 @@ paginated_multi_select() { view_indices[i]=$i done - # Escape for shell globbing without upsetting highlighters - _pm_escape_glob() { - local s="${1-}" out="" c - local i len=${#s} - for ((i = 0; i < len; i++)); do - c="${s:i:1}" - case "$c" in - $'\\' | '*' | '?' | '[' | ']') out+="\\$c" ;; - *) out+="$c" ;; - esac - done - printf '%s' "$out" - } - - # Case-insensitive fuzzy match (substring search) - _pm_match() { - local hay="$1" q="$2" - q="$(_pm_escape_glob "$q")" - local pat="*${q}*" - - shopt -s nocasematch - local ok=1 - # shellcheck disable=SC2254 # intentional glob match with a computed pattern - case "$hay" in - $pat) ok=0 ;; - esac - shopt -u nocasematch - return $ok - } - local -a selected=() local selected_count=0 # Cache selection count to avoid O(n) loops on every draw @@ -267,44 +232,13 @@ paginated_multi_select() { printf "%s%s\n" "$clear_line" "$line" >&2 } - # Rebuild the view_indices applying filter and sort + # Rebuild the view_indices applying sort rebuild_view() { - # Filter - local -a filtered=() - local effective_query="" - if [[ "$filter_mode" == "true" ]]; then - # Live editing: empty query -> show all items - effective_query="$filter_query" - if [[ -z "$effective_query" ]]; then - filtered=("${orig_indices[@]}") - else - local idx - for ((idx = 0; idx < total_items; idx++)); do - if _pm_match "${items[idx]}" "$effective_query"; then - filtered+=("$idx") - fi - done - fi - else - # Normal mode: use applied query; empty -> show all - effective_query="$applied_query" - if [[ -z "$effective_query" ]]; then - filtered=("${orig_indices[@]}") - else - local idx - for ((idx = 0; idx < total_items; idx++)); do - if _pm_match "${items[idx]}" "$effective_query"; then - filtered+=("$idx") - fi - done - fi - fi - # Sort (skip if no metadata) if [[ "$has_metadata" == "false" ]]; then - # No metadata: just use filtered list (already sorted by name naturally) - view_indices=("${filtered[@]}") - elif [[ ${#filtered[@]} -eq 0 ]]; then + # No metadata: just use original indices + view_indices=("${orig_indices[@]}") + elif [[ ${#orig_indices[@]} -eq 0 ]]; then view_indices=() else # Build sort key @@ -328,7 +262,7 @@ paginated_multi_select() { tmpfile=$(mktemp 2> /dev/null) || tmpfile="" if [[ -n "$tmpfile" ]]; then local k id - for id in "${filtered[@]}"; do + for id in "${orig_indices[@]}"; do case "$sort_mode" in date) k="${epochs[id]:-0}" ;; size) k="${sizekb[id]:-0}" ;; @@ -346,7 +280,7 @@ paginated_multi_select() { rm -f "$tmpfile" else # Fallback: no sorting - view_indices=("${filtered[@]}") + view_indices=("${orig_indices[@]}") fi fi @@ -404,34 +338,13 @@ paginated_multi_select() { # Visible slice local visible_total=${#view_indices[@]} if [[ $visible_total -eq 0 ]]; then - if [[ "$filter_mode" == "true" ]]; then - # While editing: do not show "No items available" - for ((i = 0; i < items_per_page; i++)); do - printf "${clear_line}\n" >&2 - done - printf "${clear_line}${GRAY}Type to filter | Delete | Enter Confirm | ESC Cancel${NC}\n" >&2 - printf "${clear_line}" >&2 - return - else - if [[ "$searching" == "true" ]]; then - printf "${clear_line}Searching…\n" >&2 - for ((i = 0; i < items_per_page; i++)); do - printf "${clear_line}\n" >&2 - done - printf "${clear_line}${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space | Enter | / Filter | Q Exit${NC}\n" >&2 - printf "${clear_line}" >&2 - return - else - # Post-search: truly empty list - printf "${clear_line}No items available\n" >&2 - for ((i = 0; i < items_per_page; i++)); do - printf "${clear_line}\n" >&2 - done - printf "${clear_line}${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space | Enter | / Filter | Q Exit${NC}\n" >&2 - printf "${clear_line}" >&2 - return - fi - fi + printf "${clear_line}No items available\n" >&2 + for ((i = 0; i < items_per_page; i++)); do + printf "${clear_line}\n" >&2 + done + printf "${clear_line}${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space | Enter | Q Exit${NC}\n" >&2 + printf "${clear_line}" >&2 + return fi local visible_count=$((visible_total - top_index)) @@ -465,7 +378,7 @@ paginated_multi_select() { printf "${clear_line}\n" >&2 - # Build sort and filter status + # Build sort status local sort_label="" case "$sort_mode" in date) sort_label="Date" ;; @@ -474,15 +387,6 @@ paginated_multi_select() { esac local sort_status="${sort_label}" - local filter_status="" - if [[ "$filter_mode" == "true" ]]; then - filter_status="${filter_query:-_}" - elif [[ -n "$applied_query" ]]; then - filter_status="${applied_query}" - else - filter_status="—" - fi - # Footer: single line with controls local sep=" ${GRAY}|${NC} " @@ -497,77 +401,54 @@ paginated_multi_select() { # Common menu items local nav="${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN}${NC}" local space_select="${GRAY}Space Select${NC}" - local space="${GRAY}Space${NC}" local enter="${GRAY}Enter${NC}" local exit="${GRAY}Q Exit${NC}" - if [[ "$filter_mode" == "true" ]]; then - # Filter mode: simple controls without sort - local -a _segs_filter=( - "${GRAY}Search: ${filter_status}${NC}" - "${GRAY}Delete${NC}" - "${GRAY}Enter Confirm${NC}" - "${GRAY}ESC Cancel${NC}" - ) - _print_wrapped_controls "$sep" "${_segs_filter[@]}" - else - # Normal mode - prepare dynamic items - local reverse_arrow="↑" - [[ "$sort_reverse" == "true" ]] && reverse_arrow="↓" + local reverse_arrow="↑" + [[ "$sort_reverse" == "true" ]] && reverse_arrow="↓" - local filter_text="/ Search" - [[ -n "$applied_query" ]] && filter_text="/ Clear" + local refresh="${GRAY}R Refresh${NC}" + local sort_ctrl="${GRAY}S ${sort_status}${NC}" + local order_ctrl="${GRAY}O ${reverse_arrow}${NC}" - local refresh="${GRAY}R Refresh${NC}" - local search="${GRAY}${filter_text}${NC}" - local sort_ctrl="${GRAY}S ${sort_status}${NC}" - local order_ctrl="${GRAY}O ${reverse_arrow}${NC}" + if [[ "$has_metadata" == "true" ]]; then + # With metadata: show sort controls + local term_width="${COLUMNS:-}" + [[ -z "$term_width" ]] && term_width=$(tput cols 2> /dev/null || echo 80) + [[ "$term_width" =~ ^[0-9]+$ ]] || term_width=80 - if [[ "$has_metadata" == "true" ]]; then - if [[ -n "$applied_query" ]]; then - # Filtering active: hide sort controls - local -a _segs_all=("$nav" "$space" "$enter" "$refresh" "$search" "$exit") - _print_wrapped_controls "$sep" "${_segs_all[@]}" - else - # Normal: show full controls with dynamic reduction - local term_width="${COLUMNS:-}" - [[ -z "$term_width" ]] && term_width=$(tput cols 2> /dev/null || echo 80) - [[ "$term_width" =~ ^[0-9]+$ ]] || term_width=80 + # Full controls + local -a _segs=("$nav" "$space_select" "$enter" "$refresh" "$sort_ctrl" "$order_ctrl" "$exit") - # Level 0: Full controls - local -a _segs=("$nav" "$space_select" "$enter" "$refresh" "$search" "$sort_ctrl" "$order_ctrl" "$exit") + # Calculate width + local total_len=0 seg_count=${#_segs[@]} + for i in "${!_segs[@]}"; do + total_len=$((total_len + $(_calc_len "${_segs[i]}"))) + [[ $i -lt $((seg_count - 1)) ]] && total_len=$((total_len + 3)) + done - # Calculate width - local total_len=0 seg_count=${#_segs[@]} - for i in "${!_segs[@]}"; do - total_len=$((total_len + $(_calc_len "${_segs[i]}"))) - [[ $i -lt $((seg_count - 1)) ]] && total_len=$((total_len + 3)) - done + # Level 1: Remove "Space Select" if too wide + if [[ $total_len -gt $term_width ]]; then + _segs=("$nav" "$enter" "$refresh" "$sort_ctrl" "$order_ctrl" "$exit") - # Level 1: Remove "Space Select" - if [[ $total_len -gt $term_width ]]; then - _segs=("$nav" "$enter" "$refresh" "$search" "$sort_ctrl" "$order_ctrl" "$exit") + total_len=0 + seg_count=${#_segs[@]} + for i in "${!_segs[@]}"; do + total_len=$((total_len + $(_calc_len "${_segs[i]}"))) + [[ $i -lt $((seg_count - 1)) ]] && total_len=$((total_len + 3)) + done - total_len=0 - seg_count=${#_segs[@]} - for i in "${!_segs[@]}"; do - total_len=$((total_len + $(_calc_len "${_segs[i]}"))) - [[ $i -lt $((seg_count - 1)) ]] && total_len=$((total_len + 3)) - done - - # Level 2: Remove "S ${sort_status}" - if [[ $total_len -gt $term_width ]]; then - _segs=("$nav" "$enter" "$refresh" "$search" "$order_ctrl" "$exit") - fi - fi - - _print_wrapped_controls "$sep" "${_segs[@]}" + # Level 2: Remove sort label if still too wide + if [[ $total_len -gt $term_width ]]; then + _segs=("$nav" "$enter" "$refresh" "$order_ctrl" "$exit") fi - else - # Without metadata: basic controls - local -a _segs_simple=("$nav" "$space_select" "$enter" "$refresh" "$search" "$exit") - _print_wrapped_controls "$sep" "${_segs_simple[@]}" fi + + _print_wrapped_controls "$sep" "${_segs[@]}" + else + # Without metadata: basic controls + local -a _segs_simple=("$nav" "$space_select" "$enter" "$refresh" "$exit") + _print_wrapped_controls "$sep" "${_segs_simple[@]}" fi printf "${clear_line}" >&2 } @@ -592,16 +473,6 @@ paginated_multi_select() { case "$key" in "QUIT") - if [[ "$filter_mode" == "true" ]]; then - filter_mode="false" - filter_query="" - applied_query="" - top_index=0 - cursor_pos=0 - rebuild_view - need_full_redraw=true - continue - fi cleanup return 1 ;; @@ -759,13 +630,7 @@ paginated_multi_select() { fi ;; "CHAR:s" | "CHAR:S") - if [[ "$filter_mode" == "true" ]]; then - local ch="${key#CHAR:}" - filter_query+="$ch" - rebuild_view - need_full_redraw=true - continue - elif [[ "$has_metadata" == "true" ]]; then + if [[ "$has_metadata" == "true" ]]; then # Cycle sort mode (only if metadata available) case "$sort_mode" in date) sort_mode="name" ;; @@ -776,135 +641,43 @@ paginated_multi_select() { need_full_redraw=true fi ;; - "FILTER") - # / key: toggle between filter and return - if [[ -n "$applied_query" ]]; then - # Already filtering, clear and return to full list - applied_query="" - filter_query="" - top_index=0 - cursor_pos=0 - rebuild_view - need_full_redraw=true - else - # Enter filter mode - filter_mode="true" - filter_query="" - top_index=0 - cursor_pos=0 - rebuild_view - need_full_redraw=true - fi - ;; "CHAR:j") - if [[ "$filter_mode" != "true" ]]; then - # Down navigation - if [[ ${#view_indices[@]} -gt 0 ]]; then - local absolute_index=$((top_index + cursor_pos)) - local last_index=$((${#view_indices[@]} - 1)) - if [[ $absolute_index -lt $last_index ]]; then - local visible_count=$((${#view_indices[@]} - 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 ${#view_indices[@]} ]]; then - ((top_index++)) - fi + # Down navigation (vim style) + if [[ ${#view_indices[@]} -gt 0 ]]; then + local absolute_index=$((top_index + cursor_pos)) + local last_index=$((${#view_indices[@]} - 1)) + if [[ $absolute_index -lt $last_index ]]; then + local visible_count=$((${#view_indices[@]} - 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 ${#view_indices[@]} ]]; then + ((top_index++)) fi + need_full_redraw=true fi - else - filter_query+="j" - rebuild_view - need_full_redraw=true - continue fi ;; "CHAR:k") - if [[ "$filter_mode" != "true" ]]; then - # Up navigation - if [[ ${#view_indices[@]} -gt 0 ]]; then - if [[ $cursor_pos -gt 0 ]]; then - ((cursor_pos--)) - elif [[ $top_index -gt 0 ]]; then - ((top_index--)) - fi + # Up navigation (vim style) + if [[ ${#view_indices[@]} -gt 0 ]]; then + if [[ $cursor_pos -gt 0 ]]; then + ((cursor_pos--)) + need_full_redraw=true + elif [[ $top_index -gt 0 ]]; then + ((top_index--)) + need_full_redraw=true fi - else - filter_query+="k" - rebuild_view - need_full_redraw=true - continue fi ;; - "TOUCHID") - if [[ "$filter_mode" == "true" ]]; then - filter_query+="t" - rebuild_view - need_full_redraw=true - continue - fi - ;; - "RIGHT") - if [[ "$filter_mode" == "true" ]]; then - filter_query+="l" - rebuild_view - need_full_redraw=true - continue - fi - ;; - "LEFT") - if [[ "$filter_mode" == "true" ]]; then - filter_query+="h" - rebuild_view - need_full_redraw=true - continue - fi - ;; - "MORE") - if [[ "$filter_mode" == "true" ]]; then - filter_query+="m" - rebuild_view - need_full_redraw=true - continue - fi - ;; - "UPDATE") - if [[ "$filter_mode" == "true" ]]; then - filter_query+="u" - rebuild_view - need_full_redraw=true - continue - fi - ;; - "CHAR:f" | "CHAR:F") - if [[ "$filter_mode" == "true" ]]; then - filter_query+="${key#CHAR:}" - rebuild_view - need_full_redraw=true - continue - fi - # F is currently unbound in normal mode to avoid conflict with Refresh (R) - ;; "CHAR:r" | "CHAR:R") - if [[ "$filter_mode" == "true" ]]; then - filter_query+="${key#CHAR:}" - rebuild_view - need_full_redraw=true - continue - else - # Trigger Refresh signal (Unified with Analyze) - cleanup - return 10 - fi + # Trigger Refresh signal + cleanup + return 10 ;; "CHAR:o" | "CHAR:O") - if [[ "$filter_mode" == "true" ]]; then - filter_query+="${key#CHAR:}" - rebuild_view - need_full_redraw=true - continue - elif [[ "$has_metadata" == "true" ]]; then - # O toggles reverse order (Unified Sort Order) + if [[ "$has_metadata" == "true" ]]; then + # O toggles reverse order if [[ "$sort_reverse" == "true" ]]; then sort_reverse="false" else @@ -914,40 +687,8 @@ paginated_multi_select() { need_full_redraw=true fi ;; - "DELETE") - # Backspace filter - if [[ "$filter_mode" == "true" && -n "$filter_query" ]]; then - filter_query="${filter_query%?}" - # Rebuild view to apply filter in real-time - rebuild_view - # Trigger redraw and continue to avoid drain_pending_input - need_full_redraw=true - continue - fi - ;; - CHAR:*) - if [[ "$filter_mode" == "true" ]]; then - local ch="${key#CHAR:}" - # avoid accidental leading spaces - if [[ -n "$filter_query" || "$ch" != " " ]]; then - filter_query+="$ch" - # Rebuild view to apply filter in real-time - rebuild_view - # Trigger redraw and continue to avoid drain_pending_input - need_full_redraw=true - continue - fi - fi - ;; "ENTER") - if [[ "$filter_mode" == "true" ]]; then - applied_query="$filter_query" - filter_mode="false" - # Preserve cursor/top_index so navigation during search is respected - rebuild_view - # Fall through to confirmation logic - fi - # In normal mode: smart Enter behavior + # Smart Enter behavior # 1. Check if any items are already selected local has_selection=false for ((i = 0; i < total_items; i++)); do diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index b54fe08..ba5cf38 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -169,6 +169,9 @@ remove_file_list() { batch_uninstall_applications() { local total_size_freed=0 + # Trap to clean up spinner on interrupt + trap 'stop_inline_spinner 2>/dev/null; echo ""; return 130' INT TERM + # shellcheck disable=SC2154 if [[ ${#selected_apps[@]} -eq 0 ]]; then log_warning "No applications selected for uninstallation" @@ -181,33 +184,37 @@ batch_uninstall_applications() { local total_estimated_size=0 local -a app_details=() + # Cache current user outside loop + local current_user=$(whoami) + if [[ -t 1 ]]; then start_inline_spinner "Scanning files..."; fi for selected_app in "${selected_apps[@]}"; do [[ -z "$selected_app" ]] && continue IFS='|' read -r _ app_path app_name bundle_id _ _ <<< "$selected_app" - # Check running app by bundle executable if available. + # Check running app by bundle executable if available local exec_name="" - if [[ -e "$app_path/Contents/Info.plist" ]]; then - exec_name=$(defaults read "$app_path/Contents/Info.plist" CFBundleExecutable 2> /dev/null || echo "") + local info_plist="$app_path/Contents/Info.plist" + if [[ -e "$info_plist" ]]; then + exec_name=$(defaults read "$info_plist" CFBundleExecutable 2>/dev/null || echo "") fi - local check_pattern="${exec_name:-$app_name}" - if pgrep -x "$check_pattern" > /dev/null 2>&1; then + if pgrep -qx "${exec_name:-$app_name}" 2>/dev/null; then running_apps+=("$app_name") fi - # Check if it's a Homebrew cask (deterministic: resolved path in Caskroom) - local cask_name="" - cask_name=$(get_brew_cask_name "$app_path" || echo "") - local is_brew_cask="false" - [[ -n "$cask_name" ]] && is_brew_cask="true" + # Check if it's a Homebrew cask (only if app is symlinked from Caskroom) + local cask_name="" is_brew_cask="false" + local resolved_path=$(readlink "$app_path" 2>/dev/null || echo "") + if [[ "$resolved_path" == */Caskroom/* ]]; then + # Extract cask name using bash parameter expansion (faster than sed) + local tmp="${resolved_path#*/Caskroom/}" + cask_name="${tmp%%/*}" + [[ -n "$cask_name" ]] && is_brew_cask="true" + fi - # Full file scanning for ALL apps (including Homebrew casks) - # brew uninstall --cask does NOT remove user data (caches, prefs, app support) - # Mole's value is cleaning those up, so we must scan for them + # Check if sudo is needed local needs_sudo=false local app_owner=$(get_file_owner "$app_path") - local current_user=$(whoami) if [[ ! -w "$(dirname "$app_path")" ]] || [[ "$app_owner" == "root" ]] || [[ -n "$app_owner" && "$app_owner" != "$current_user" ]]; then @@ -406,11 +413,12 @@ batch_uninstall_applications() { fi # Remove the application only if not running. + # Stop spinner before any removal attempt (avoids mixed output on errors) + [[ -t 1 ]] && stop_inline_spinner + local used_brew_successfully=false if [[ -z "$reason" ]]; then if [[ "$is_brew_cask" == "true" && -n "$cask_name" ]]; then - # Stop spinner before brew output - [[ -t 1 ]] && stop_inline_spinner # Use brew_uninstall_cask helper (handles env vars, timeout, verification) if brew_uninstall_cask "$cask_name" "$app_path"; then used_brew_successfully=true @@ -425,7 +433,6 @@ batch_uninstall_applications() { elif [[ "$needs_sudo" == true ]]; then if ! safe_sudo_remove "$app_path"; then local app_owner=$(get_file_owner "$app_path") - local current_user=$(whoami) if [[ -n "$app_owner" && "$app_owner" != "$current_user" && "$app_owner" != "root" ]]; then reason="owned by $app_owner" else @@ -460,13 +467,12 @@ batch_uninstall_applications() { fi fi - # Stop spinner and show success + # Show success if [[ -t 1 ]]; then - stop_inline_spinner if [[ ${#app_details[@]} -gt 1 ]]; then - echo -e "\r\033[K${GREEN}✓${NC} [$current_index/${#app_details[@]}] ${app_name}" + echo -e "${GREEN}✓${NC} [$current_index/${#app_details[@]}] ${app_name}" else - echo -e "\r\033[K${GREEN}✓${NC} ${app_name}" + echo -e "${GREEN}✓${NC} ${app_name}" fi fi @@ -477,13 +483,12 @@ batch_uninstall_applications() { ((total_items++)) success_items+=("$app_name") else - # Stop spinner and show failure + # Show failure if [[ -t 1 ]]; then - stop_inline_spinner if [[ ${#app_details[@]} -gt 1 ]]; then - echo -e "\r\033[K${RED}✗${NC} [$current_index/${#app_details[@]}] ${app_name} ${GRAY}($reason)${NC}" + echo -e "${ICON_ERROR} [$current_index/${#app_details[@]}] ${app_name} ${GRAY}($reason)${NC}" else - echo -e "\r\033[K${RED}✗${NC} ${app_name} failed: $reason" + echo -e "${ICON_ERROR} ${app_name} failed: $reason" fi fi