1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-07 11:38:28 +00:00

refactor(uninstall): remove search and optimize scan performance

- Remove complex search/filter feature from menu (simplify UX)
  - Optimize Homebrew cask detection (only check Caskroom symlinks)
  - Cache whoami outside loop, use bash parameter expansion
  - Fix spinner overlap with error messages
  - Add trap for Ctrl+C cleanup
  - Use ICON_ERROR for failure messages
This commit is contained in:
Tw93
2026-01-19 16:53:51 +08:00
parent 213dcf587d
commit d25c9f7637
2 changed files with 107 additions and 361 deletions

View File

@@ -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

View File

@@ -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