From bc1af7e35dde6d030775e958eeeab9df99d87aba Mon Sep 17 00:00:00 2001 From: Else00 <48489849+Else00@users.noreply.github.com> Date: Tue, 14 Oct 2025 03:40:47 +0200 Subject: [PATCH 01/17] feat(menu): add sort (date/name/size), live filter, reverse; visible-only A/N; responsive footer (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paginated menu: - Sorting: press S/s to cycle Date → Name → Size; press R/r to reverse. - Live filter: press F/f to enter; case-insensitive substring; prefix with ' to anchor at start; DELETE to backspace; ENTER to apply; ESC to cancel. Shows "searching…" while rebuilding. - Selection scope: A (All) and N (None) now work on the currently visible items only (after filter/sort), not the entire list. - Footer: adds A/N to the help line and wraps only at ' | ' separators so labels are never broken; adapts to terminal width. - Internals: view_indices mapping for filtered/sorted view; glob-safe matching via _pm_escape_glob; drain_pending_input; robust stty restore; optional MOLE_MANAGED_ALT_SCREEN; cleanup unsets MOLE_READ_KEY_FORCE_CHAR; shellcheck clean. common.sh: - read_key supports a raw typing mode (MOLE_READ_KEY_FORCE_CHAR=1) emitting CHAR:; ENTER/DELETE/ESC handled. - Uppercase A/N/R mappings (ALL/NONE/RETRY), printable-key detection, better ESC sequence handling. app_selector.sh: - Builds and exports per-item metadata CSV for epochs and size_kb via MOLE_MENU_META_EPOCHS and MOLE_MENU_META_SIZEKB; unsets them after the menu. - Menu options keep display text; sorting/filtering use metadata. uninstall.sh: - Computes app_size_kb using du -sk for numeric sorting while keeping human-readable size; writes it as the final field. - load_applications reads the new size_kb field. Notes: - Footer grew due to new commands; responsive wrapping prevents mid-word breaks. - ./tests/run.sh: only the two upstream failures remain (unchanged by this patch). --- bin/uninstall.sh | 10 +- lib/app_selector.sh | 32 ++- lib/common.sh | 39 +++- lib/paginated_menu.sh | 477 +++++++++++++++++++++++++++++++++++++----- 4 files changed, 496 insertions(+), 62 deletions(-) diff --git a/bin/uninstall.sh b/bin/uninstall.sh index 942f2d1..135c8a4 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -245,7 +245,10 @@ scan_applications() { # Parallel size calculation local app_size="N/A" + local app_size_kb="0" if [[ -d "$app_path" ]]; then + # numeric size (KB) for sorting + human-readable for display + app_size_kb=$(du -sk "$app_path" 2> /dev/null | awk '{print $1}' || echo "0") app_size=$(du -sh "$app_path" 2> /dev/null | cut -f1 || echo "N/A") fi @@ -297,7 +300,8 @@ scan_applications() { fi # Write to output file atomically - echo "${last_used_epoch}|${app_path}|${display_name}|${bundle_id}|${app_size}|${last_used}" >> "$output_file" + # Fields: epoch|app_path|display_name|bundle_id|size_human|last_used|size_kb + echo "${last_used_epoch}|${app_path}|${display_name}|${bundle_id}|${app_size}|${last_used}|${app_size_kb}" >> "$output_file" } export -f process_app_metadata @@ -380,8 +384,8 @@ load_applications() { selection_state=() # Read apps into array - while IFS='|' read -r epoch app_path app_name bundle_id size last_used; do - apps_data+=("$epoch|$app_path|$app_name|$bundle_id|$size|$last_used") + while IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb; do + apps_data+=("$epoch|$app_path|$app_name|$bundle_id|$size|$last_used|${size_kb:-0}") selection_state+=(false) done < "$apps_file" diff --git a/lib/app_selector.sh b/lib/app_selector.sh index c0a1830..5a86403 100755 --- a/lib/app_selector.sh +++ b/lib/app_selector.sh @@ -33,18 +33,44 @@ select_apps_for_uninstall() { # Build menu options local -a menu_options=() + # Prepare metadata (comma-separated) for sorting/filtering inside the menu + local epochs_csv="" + local sizekb_csv="" + local idx=0 for app_data in "${apps_data[@]}"; do - # Ignore metadata fields not needed for menu display - IFS='|' read -r _ _ display_name _ size last_used <<< "$app_data" + # Keep extended field 7 (size_kb) if present + IFS='|' read -r epoch _ display_name _ size last_used size_kb <<< "$app_data" menu_options+=("$(format_app_display "$display_name" "$size" "$last_used")") + # Build csv lists (avoid trailing commas) + if [[ $idx -eq 0 ]]; then + epochs_csv="${epoch:-0}" + sizekb_csv="${size_kb:-0}" + else + epochs_csv+=",${epoch:-0}" + sizekb_csv+=",${size_kb:-0}" + fi + ((idx++)) done + # 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 + # The menu will gracefully fallback if these are unset or malformed. + export MOLE_MENU_META_EPOCHS="$epochs_csv" + export MOLE_MENU_META_SIZEKB="$sizekb_csv" + # Optional: allow default sort override via env (date|name|size) + # export MOLE_MENU_SORT_DEFAULT="${MOLE_MENU_SORT_DEFAULT:-date}" + # Use paginated menu - result will be stored in MOLE_SELECTION_RESULT # Note: paginated_multi_select enters alternate screen and handles clearing MOLE_SELECTION_RESULT="" paginated_multi_select "Select Apps to Remove" "${menu_options[@]}" local exit_code=$? + # Clean env leakage for safety + unset MOLE_MENU_META_EPOCHS MOLE_MENU_META_SIZEKB + # leave MOLE_MENU_SORT_DEFAULT untouched if user set it globally + if [[ $exit_code -ne 0 ]]; then echo "Cancelled" return 1 @@ -56,11 +82,9 @@ select_apps_for_uninstall() { fi # Build selected apps array (global variable in bin/uninstall.sh) - # Clear existing selections - compatible with bash 3.2 selected_apps=() # Parse indices and build selected apps array - # MOLE_SELECTION_RESULT is comma-separated list of indices from the paginated menu IFS=',' read -r -a indices_array <<< "$MOLE_SELECTION_RESULT" for idx in "${indices_array[@]}"; do diff --git a/lib/common.sh b/lib/common.sh index 9ab9251..5eee8da 100755 --- a/lib/common.sh +++ b/lib/common.sh @@ -176,9 +176,29 @@ show_cursor() { # Keyboard input handling (simple and robust) read_key() { local key rest - # Use macOS bash 3.2 compatible read syntax IFS= read -r -s -n 1 key || return 1 + # Raw typing mode (filter): map most keys to CHAR: + if [[ "${MOLE_READ_KEY_FORCE_CHAR:-}" == "1" ]]; then + # Some terminals return empty on Enter with -n1 + if [[ -z "$key" ]]; then + echo "ENTER" + return 0 + fi + case "$key" in + $'\n'|$'\r') echo "ENTER" ;; + $'\x7f'|$'\x08') echo "DELETE" ;; + $'\x1b') echo "QUIT" ;; # ESC cancels filter + *) + case "$key" in + [[:print:]]) echo "CHAR:$key" ;; + *) echo "OTHER" ;; + esac + ;; + esac + return 0 + fi + # Some terminals can yield empty on Enter with -n1; treat as ENTER if [[ -z "$key" ]]; then echo "ENTER" @@ -189,14 +209,13 @@ read_key() { $'\n' | $'\r') echo "ENTER" ;; ' ') echo "SPACE" ;; 'q' | 'Q') echo "QUIT" ;; - 'a' | 'A') echo "ALL" ;; - 'n' | 'N') echo "NONE" ;; - 'd' | 'D') echo "DELETE" ;; - 'r' | 'R') echo "RETRY" ;; + 'A') echo "ALL" ;; + 'N') echo "NONE" ;; + 'R') echo "RETRY" ;; '?') echo "HELP" ;; 'o' | 'O') echo "OPEN" ;; $'\x03') echo "QUIT" ;; # Ctrl+C - $'\x7f' | $'\x08') echo "DELETE" ;; # Delete key (labeled "delete" on Mac, actually backspace) + $'\x7f' | $'\x08') echo "DELETE" ;; # Backspace/Delete key $'\x1b') # ESC sequence - could be arrow key, delete key, or ESC alone # Read the next two bytes within 1s @@ -241,7 +260,13 @@ read_key() { echo "QUIT" fi ;; - *) echo "OTHER" ;; + *) + # Printable ASCII -> expose as CHAR: (for live filtering) + case "$key" in + [[:print:]]) echo "CHAR:$key" ;; + *) echo "OTHER" ;; + esac + ;; esac } diff --git a/lib/paginated_menu.sh b/lib/paginated_menu.sh index 4496a6b..c858656 100755 --- a/lib/paginated_menu.sh +++ b/lib/paginated_menu.sh @@ -7,6 +7,27 @@ set -euo pipefail enter_alt_screen() { tput smcup 2> /dev/null || true; } leave_alt_screen() { tput rmcup 2> /dev/null || true; } +# Parse CSV into newline list (Bash 3.2) +_pm_parse_csv_to_array() { + local csv="${1:-}" + if [[ -z "$csv" ]]; then + return 0 + fi + local IFS=',' + for _tok in $csv; do + printf "%s\n" "$_tok" + done +} + +# Non-blocking input drain (bash 3.2) +drain_pending_input() { + local _k + # -t 0 is non-blocking; -n 1 consumes one byte at a time + while IFS= read -r -s -n 1 -t 0 _k; do + IFS= read -r -s -n 1 _k || break + done +} + # Main paginated multi-select menu function paginated_multi_select() { local title="$1" @@ -27,6 +48,74 @@ paginated_multi_select() { local items_per_page=15 local cursor_pos=0 local top_index=0 + local filter_query="" + local filter_mode="false" # filter mode toggle + local sort_mode="${MOLE_MENU_SORT_DEFAULT:-date}" # date|name|size + local 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 + # sizekb[i] -> size in KB (numeric) for item i + local -a epochs=() + local -a sizekb=() + if [[ -n "${MOLE_MENU_META_EPOCHS:-}" ]]; then + while IFS= read -r v; do epochs+=("${v:-0}"); done < <(_pm_parse_csv_to_array "$MOLE_MENU_META_EPOCHS") + fi + if [[ -n "${MOLE_MENU_META_SIZEKB:-}" ]]; then + while IFS= read -r v; do sizekb+=("${v:-0}"); done < <(_pm_parse_csv_to_array "$MOLE_MENU_META_SIZEKB") + fi + + # Index mappings + local -a orig_indices=() + local -a view_indices=() + local i + for ((i = 0; i < total_items; i++)); do + orig_indices[i]=$i + 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&2; } + # Print footer lines wrapping only at separators + _print_wrapped_controls() { + local sep="$1"; shift + local -a segs=("$@") + + local cols="${COLUMNS:-}" + [[ -z "$cols" ]] && cols=$(tput cols 2>/dev/null || echo 80) + + _strip_ansi_len() { + local text="$1" + local stripped + stripped=$(printf "%s" "$text" | LC_ALL=C awk '{gsub(/\033\[[0-9;]*[A-Za-z]/,""); print}') + printf "%d" "${#stripped}" + } + + local line="" s candidate + local clear_line=$'\r\033[2K' + for s in "${segs[@]}"; do + if [[ -z "$line" ]]; then + candidate="$s" + else + candidate="$line${sep}${s}" + fi + if (( $(_strip_ansi_len "$candidate") > cols )); then + printf "%s%s\n" "$clear_line" "$line" >&2 + line="$s" + else + line="$candidate" + fi + done + printf "%s%s\n" "$clear_line" "$line" >&2 + } + + # Rebuild the view_indices applying filter and sort + rebuild_view() { + # Filter + local -a filtered=() + local effective_query="" + if [[ "$filter_mode" == "true" ]]; then + # Live editing: empty query -> show nothing; non-empty -> match + effective_query="$filter_query" + if [[ -z "$effective_query" ]]; then + filtered=() + 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 + local tmpfile + tmpfile=$(mktemp) || tmpfile="" + if [[ -n "$tmpfile" ]]; then + : > "$tmpfile" + local k id + if [[ ${#filtered[@]} -gt 0 ]]; then + for id in "${filtered[@]}"; do + case "$sort_mode" in + date) k="${epochs[id]:-${id}}" ;; + size) k="${sizekb[id]:-0}" ;; + name|*) k="${items[id]}|${id}" ;; + esac + printf "%s\t%s\n" "$k" "$id" >> "$tmpfile" + done + fi + + # Build sort key once and stream results into view_indices + local sort_key + if [[ "$sort_mode" == "date" || "$sort_mode" == "size" ]]; then + sort_key="-k1,1n" + [[ "$sort_reverse" == "true" ]] && sort_key="-k1,1nr" + else + sort_key="-k1,1f" + [[ "$sort_reverse" == "true" ]] && sort_key="-k1,1fr" + fi + + view_indices=() + while IFS=$'\t' read -r _key _id; do + [[ -z "$_id" ]] && continue + view_indices+=("$_id") + done < <(LC_ALL=C sort -t $'\t' $sort_key -- "$tmpfile") + + rm -f "$tmpfile" + else + view_indices=("${filtered[@]}") + fi + + # Clamp cursor into visible range + local visible_count=${#view_indices[@]} + local max_top + if [[ $visible_count -gt $items_per_page ]]; then + max_top=$((visible_count - items_per_page)) + else + max_top=0 + fi + [[ $top_index -gt $max_top ]] && top_index=$max_top + local current_visible=$((visible_count - top_index)) + [[ $current_visible -gt $items_per_page ]] && current_visible=$items_per_page + if [[ $cursor_pos -ge $current_visible ]]; then + cursor_pos=$((current_visible > 0 ? current_visible - 1 : 0)) + fi + [[ $cursor_pos -lt 0 ]] && cursor_pos=0 + } + + # Initial view (default sort) + rebuild_view + render_item() { - local idx=$1 is_current=$2 + # $1: visible row index (0..items_per_page-1 in current window) + # $2: is_current flag + local vrow=$1 is_current=$2 + local idx=$((top_index + vrow)) + local real="${view_indices[idx]:--1}" + [[ $real -lt 0 ]] && return local checkbox="$ICON_EMPTY" - [[ ${selected[idx]} == true ]] && checkbox="$ICON_SOLID" + [[ ${selected[real]} == true ]] && checkbox="$ICON_SOLID" if [[ $is_current == true ]]; then - printf "\r\033[2K${BLUE}${ICON_ARROW} %s %s${NC}\n" "$checkbox" "${items[idx]}" >&2 + printf "\r\033[2K${BLUE}${ICON_ARROW} %s %s${NC}\n" "$checkbox" "${items[real]}" >&2 else - printf "\r\033[2K %s %s\n" "$checkbox" "${items[idx]}" >&2 + printf "\r\033[2K %s %s\n" "$checkbox" "${items[real]}" >&2 fi } # Draw the complete menu draw_menu() { - # Move to home position without clearing (reduces flicker) printf "\033[H" >&2 - - # Clear each line as we go instead of clearing entire screen local clear_line="\r\033[2K" - # Count selections for header display + # Count selections local selected_count=0 for ((i = 0; i < total_items; i++)); do [[ ${selected[i]} == true ]] && ((selected_count++)) @@ -120,24 +336,71 @@ paginated_multi_select() { # Header printf "${clear_line}${PURPLE}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2 + # Sort + Filter status + local sort_label="" + case "$sort_mode" in + date) sort_label="Date" ;; + name) sort_label="Name" ;; + size) sort_label="Size" ;; + esac + local arrow="↑" + [[ "$sort_reverse" == "true" ]] && arrow="↓" - if [[ $total_items -eq 0 ]]; then - printf "${clear_line}${GRAY}No items available${NC}\n" >&2 - printf "${clear_line}\n" >&2 - printf "${clear_line}${GRAY}Q/ESC${NC} Quit\n" >&2 - printf "${clear_line}" >&2 - return + local filter_label="" + if [[ "$filter_mode" == "true" ]]; then + filter_label="${YELLOW}${filter_query:-}${NC}${GRAY} [editing]${NC}" + else + if [[ -n "$applied_query" ]]; then + if [[ "$searching" == "true" ]]; then + filter_label="${GREEN}${applied_query}${NC}${GRAY} [searching…]${NC}" + else + filter_label="${GREEN}${applied_query}${NC}" + fi + else + filter_label="${GRAY}—${NC}" + fi + fi + printf "${clear_line}${GRAY}Sort:${NC} %s %s ${GRAY}|${NC} ${GRAY}Filter:${NC} %s\n" "$sort_label" "$arrow" "$filter_label" >&2 + + # Filter-mode hint line + if [[ "$filter_mode" == "true" ]]; then + printf "${clear_line}${GRAY}Tip:${NC} prefix with ${YELLOW}'${NC} to match from start\n" >&2 fi - if [[ $top_index -gt $((total_items - 1)) ]]; then - if [[ $total_items -gt $items_per_page ]]; then - top_index=$((total_items - items_per_page)) + # 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 + 2; i++)); do + printf "${clear_line}\n" >&2 + done + printf "${clear_line}${GRAY}Type to filter${NC} ${GRAY}|${NC} ${GRAY}Delete${NC} Backspace ${GRAY}|${NC} ${GRAY}Enter${NC} Apply ${GRAY}|${NC} ${GRAY}ESC${NC} Cancel\n" >&2 + printf "${clear_line}" >&2 + return else - top_index=0 + if [[ "$searching" == "true" ]]; then + printf "${clear_line}${GRAY}Searching…${NC}\n" >&2 + for ((i = 0; i < items_per_page + 2; i++)); do + printf "${clear_line}\n" >&2 + done + printf "${clear_line}${GRAY}${ICON_NAV_UP}/${ICON_NAV_DOWN}${NC} Navigate ${GRAY}|${NC} ${GRAY}Space${NC} Select ${GRAY}|${NC} ${GRAY}Enter${NC} Confirm ${GRAY}|${NC} ${GRAY}S/s${NC} Sort ${GRAY}|${NC} ${GRAY}R/r${NC} Reverse ${GRAY}|${NC} ${GRAY}F/f${NC} Filter ${GRAY}|${NC} ${GRAY}Q/ESC${NC} Quit\n" >&2 + printf "${clear_line}" >&2 + return + else + # Post-search: truly empty list + printf "${clear_line}${GRAY}No items available${NC}\n" >&2 + for ((i = 0; i < items_per_page + 2; i++)); do + printf "${clear_line}\n" >&2 + done + printf "${clear_line}${GRAY}${ICON_NAV_UP}/${ICON_NAV_DOWN}${NC} Navigate ${GRAY}|${NC} ${GRAY}Space${NC} Select ${GRAY}|${NC} ${GRAY}Enter${NC} Confirm ${GRAY}|${NC} ${GRAY}S${NC} Sort ${GRAY}|${NC} ${GRAY}R/r${NC} Reverse ${GRAY}|${NC} ${GRAY}F${NC} Filter ${GRAY}|${NC} ${GRAY}Q/ESC${NC} Quit\n" >&2 + printf "${clear_line}" >&2 + return + fi fi fi - local visible_count=$((total_items - top_index)) + local visible_count=$((visible_total - top_index)) [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page [[ $visible_count -le 0 ]] && visible_count=1 if [[ $cursor_pos -ge $visible_count ]]; then @@ -150,13 +413,13 @@ paginated_multi_select() { # Items for current window local start_idx=$top_index local end_idx=$((top_index + items_per_page - 1)) - [[ $end_idx -ge $total_items ]] && end_idx=$((total_items - 1)) + [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) for ((i = start_idx; i <= end_idx; i++)); do [[ $i -lt 0 ]] && continue local is_current=false [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true - render_item $i $is_current + render_item $((i - start_idx)) $is_current done # Fill empty slots to clear previous content @@ -166,11 +429,31 @@ paginated_multi_select() { printf "${clear_line}\n" >&2 done - # Clear any remaining lines at bottom printf "${clear_line}\n" >&2 - printf "${clear_line}${GRAY}${ICON_NAV_UP}/${ICON_NAV_DOWN}${NC} Navigate ${GRAY}|${NC} ${GRAY}Space${NC} Select ${GRAY}|${NC} ${GRAY}Enter${NC} Confirm ${GRAY}|${NC} ${GRAY}Q/ESC${NC} Quit\n" >&2 - - # Clear one more line to ensure no artifacts + # Footer with wrapped controls + local sep=" ${GRAY}|${NC} " + if [[ "$filter_mode" == "true" ]]; then + local -a _segs_filter=( + "${GRAY}Type to filter${NC}" + "${GRAY}Delete${NC} Backspace" + "${GRAY}Enter${NC} Apply" + "${GRAY}ESC${NC} Cancel" + ) + _print_wrapped_controls "$sep" "${_segs_filter[@]}" + else + local -a _segs_normal=( + "${GRAY}${ICON_NAV_UP}/${ICON_NAV_DOWN}${NC} Navigate" + "${GRAY}Space${NC} Select" + "${GRAY}Enter${NC} Confirm" + "${GRAY}S/s${NC} Sort" + "${GRAY}R/r${NC} Reverse" + "${GRAY}F/f${NC} Filter" + "${GRAY}A${NC} All" + "${GRAY}N${NC} None" + "${GRAY}Q/ESC${NC} Quit" + ) + _print_wrapped_controls "$sep" "${_segs_normal[@]}" + fi printf "${clear_line}" >&2 } @@ -184,7 +467,13 @@ Help - Navigation Controls ${ICON_NAV_UP} / ${ICON_NAV_DOWN} Navigate up/down Space Select/deselect item Enter Confirm selection - Q / ESC Exit + S Change sort mode (Date / Name / Size) + R Reverse current sort (asc/desc) + F Toggle filter mode, type to filter (case-insensitive; prefix with ' to match from start) + A Select all (visible items) + N Deselect all (visible items) + Delete Backspace filter (in filter mode) + Q / ESC Exit (ESC exits filter mode first) Press any key to continue... EOF @@ -194,15 +483,25 @@ EOF # Main interaction loop while true; do draw_menu - local key=$(read_key) + local key + key=$(read_key) case "$key" in "QUIT") - cleanup - return 1 - ;; + if [[ "$filter_mode" == "true" ]]; then + filter_mode="false" + unset MOLE_READ_KEY_FORCE_CHAR + filter_query="" + applied_query="" + top_index=0; cursor_pos=0 + rebuild_view + continue + fi + cleanup + return 1 + ;; "UP") - if [[ $total_items -eq 0 ]]; then + if [[ ${#view_indices[@]} -eq 0 ]]; then : elif [[ $cursor_pos -gt 0 ]]; then ((cursor_pos--)) @@ -211,19 +510,20 @@ EOF fi ;; "DOWN") - if [[ $total_items -eq 0 ]]; then + if [[ ${#view_indices[@]} -eq 0 ]]; then : else local absolute_index=$((top_index + cursor_pos)) - if [[ $absolute_index -lt $((total_items - 1)) ]]; then - local visible_count=$((total_items - top_index)) + 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 $total_items ]]; then + elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then ((top_index++)) - visible_count=$((total_items - top_index)) + visible_count=$((${#view_indices[@]} - top_index)) [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page if [[ $cursor_pos -ge $visible_count ]]; then cursor_pos=$((visible_count - 1)) @@ -234,27 +534,109 @@ EOF ;; "SPACE") local idx=$((top_index + cursor_pos)) - if [[ $idx -lt $total_items ]]; then - if [[ ${selected[idx]} == true ]]; then - selected[idx]=false + if [[ $idx -lt ${#view_indices[@]} ]]; then + local real="${view_indices[idx]}" + if [[ ${selected[real]} == true ]]; then + selected[real]=false else - selected[idx]=true + selected[real]=true fi fi ;; "ALL") - for ((i = 0; i < total_items; i++)); do - selected[i]=true - done + # Select only currently visible (filtered) rows + if [[ ${#view_indices[@]} -gt 0 ]]; then + for real in "${view_indices[@]}"; do + selected[real]=true + done + fi ;; "NONE") - for ((i = 0; i < total_items; i++)); do - selected[i]=false - done + # Deselect only currently visible (filtered) rows + if [[ ${#view_indices[@]} -gt 0 ]]; then + for real in "${view_indices[@]}"; do + selected[real]=false + done + fi + ;; + "RETRY") + # 'R' toggles reverse order + if [[ "$sort_reverse" == "true" ]]; then + sort_reverse="false" + else + sort_reverse="true" + fi + rebuild_view ;; "HELP") show_help ;; + "CHAR:s"|"CHAR:S") + if [[ "$filter_mode" == "true" ]]; then + local ch="${key#CHAR:}" + filter_query+="$ch" + else + case "$sort_mode" in + date) sort_mode="name" ;; + name) sort_mode="size" ;; + size) sort_mode="date" ;; + esac + rebuild_view + fi + ;; + "CHAR:f"|"CHAR:F") + if [[ "$filter_mode" == "true" ]]; then + filter_query+="f" + else + filter_mode="true" + export MOLE_READ_KEY_FORCE_CHAR=1 + filter_query="" # start empty -> 0 results + top_index=0 # reset viewport + cursor_pos=0 + rebuild_view + fi + ;; + "CHAR:r") + # lower-case r: behave like reverse when NOT in filter mode + if [[ "$filter_mode" == "true" ]]; then + filter_query+="r" + else + if [[ "$sort_reverse" == "true" ]]; then + sort_reverse="false" + else + sort_reverse="true" + fi + rebuild_view + fi + ;; + "DELETE") + # Backspace filter + if [[ "$filter_mode" == "true" && -n "$filter_query" ]]; then + filter_query="${filter_query%?}" + fi + ;; + CHAR:*) + if [[ "$filter_mode" == "true" ]]; then + local ch="${key#CHAR:}" + # avoid accidental leading spaces + if [[ -n "$filter_query" || "$ch" != " " ]]; then + filter_query+="$ch" + fi + fi + ;; "ENTER") - # Store result in global variable instead of returning via stdout + if [[ "$filter_mode" == "true" ]]; then + applied_query="$filter_query" + filter_mode="false" + unset MOLE_READ_KEY_FORCE_CHAR + top_index=0; cursor_pos=0 + + searching="true" + draw_menu # paint "searching..." + drain_pending_input # drop any extra keypresses (e.g., double-Enter) + rebuild_view + searching="false" + draw_menu + continue + fi local -a selected_indices=() for ((i = 0; i < total_items; i++)); do if [[ ${selected[i]} == true ]]; then @@ -263,7 +645,6 @@ EOF done # Allow empty selection - don't auto-select cursor position - # This fixes the bug where unselecting all items would still select the last cursor position local final_result="" if [[ ${#selected_indices[@]} -gt 0 ]]; then local IFS=',' From 332b83afa36164670d8c7d9b9af603fe370e50f3 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 6 Oct 2025 10:54:22 +0800 Subject: [PATCH 02/17] chore: trigger recount From 027261a3e1a654fb7d461ca54207b9a429fb8311 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Tue, 14 Oct 2025 17:26:27 +0800 Subject: [PATCH 03/17] Fix whitelist issue caused by retrieval list --- lib/common.sh | 6 +- lib/paginated_menu.sh | 299 +++++++++++++++++++-------------------- lib/simple_menu.sh | 292 ++++++++++++++++++++++++++++++++++++++ lib/whitelist_manager.sh | 2 +- 4 files changed, 442 insertions(+), 157 deletions(-) create mode 100755 lib/simple_menu.sh diff --git a/lib/common.sh b/lib/common.sh index 5eee8da..fc5afb2 100755 --- a/lib/common.sh +++ b/lib/common.sh @@ -208,12 +208,10 @@ read_key() { case "$key" in $'\n' | $'\r') echo "ENTER" ;; ' ') echo "SPACE" ;; - 'q' | 'Q') echo "QUIT" ;; - 'A') echo "ALL" ;; - 'N') echo "NONE" ;; + 'Q') echo "QUIT" ;; 'R') echo "RETRY" ;; - '?') echo "HELP" ;; 'o' | 'O') echo "OPEN" ;; + '/') echo "FILTER" ;; # Trigger filter mode $'\x03') echo "QUIT" ;; # Ctrl+C $'\x7f' | $'\x08') echo "DELETE" ;; # Backspace/Delete key $'\x1b') diff --git a/lib/paginated_menu.sh b/lib/paginated_menu.sh index c858656..a52283b 100755 --- a/lib/paginated_menu.sh +++ b/lib/paginated_menu.sh @@ -4,8 +4,16 @@ set -euo pipefail # Terminal control functions -enter_alt_screen() { tput smcup 2> /dev/null || true; } -leave_alt_screen() { tput rmcup 2> /dev/null || true; } +enter_alt_screen() { + if command -v tput > /dev/null 2>&1 && [[ -t 1 ]]; then + tput smcup 2> /dev/null || true + fi +} +leave_alt_screen() { + if command -v tput > /dev/null 2>&1 && [[ -t 1 ]]; then + tput rmcup 2> /dev/null || true + fi +} # Parse CSV into newline list (Bash 3.2) _pm_parse_csv_to_array() { @@ -61,11 +69,19 @@ paginated_multi_select() { # sizekb[i] -> size in KB (numeric) for item i local -a epochs=() local -a sizekb=() + local has_metadata="false" if [[ -n "${MOLE_MENU_META_EPOCHS:-}" ]]; then while IFS= read -r v; do epochs+=("${v:-0}"); done < <(_pm_parse_csv_to_array "$MOLE_MENU_META_EPOCHS") + has_metadata="true" fi if [[ -n "${MOLE_MENU_META_SIZEKB:-}" ]]; then while IFS= read -r v; do sizekb+=("${v:-0}"); done < <(_pm_parse_csv_to_array "$MOLE_MENU_META_SIZEKB") + has_metadata="true" + fi + + # If no metadata, force name sorting and disable sorting controls + if [[ "$has_metadata" == "false" && "$sort_mode" != "name" ]]; then + sort_mode="name" fi # Index mappings @@ -91,20 +107,11 @@ paginated_multi_select() { printf '%s' "$out" } - # Case-insensitive: substring by default, prefix if query starts with ' + # Case-insensitive fuzzy match (substring search) _pm_match() { - local hay="$1" q="$2" anchored=0 - if [[ "$q" == \'* ]]; then - anchored=1 - q="${q:1}" - fi + local hay="$1" q="$2" q="$(_pm_escape_glob "$q")" - local pat - if [[ $anchored -eq 1 ]]; then - pat="${q}*" - else - pat="*${q}*" - fi + local pat="*${q}*" shopt -s nocasematch local ok=1 @@ -221,10 +228,10 @@ paginated_multi_select() { local -a filtered=() local effective_query="" if [[ "$filter_mode" == "true" ]]; then - # Live editing: empty query -> show nothing; non-empty -> match + # Live editing: empty query -> show all items effective_query="$filter_query" if [[ -z "$effective_query" ]]; then - filtered=() + filtered=("${orig_indices[@]}") else local idx for ((idx = 0; idx < total_items; idx++)); do @@ -248,42 +255,54 @@ paginated_multi_select() { fi fi - # Sort - local tmpfile - tmpfile=$(mktemp) || tmpfile="" - if [[ -n "$tmpfile" ]]; then - : > "$tmpfile" - local k id - if [[ ${#filtered[@]} -gt 0 ]]; then + # 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 + view_indices=() + else + # Build sort key + local sort_key + if [[ "$sort_mode" == "date" ]]; then + # Date: ascending by default (oldest first) + sort_key="-k1,1n" + [[ "$sort_reverse" == "true" ]] && sort_key="-k1,1nr" + elif [[ "$sort_mode" == "size" ]]; then + # Size: descending by default (largest first) + sort_key="-k1,1nr" + [[ "$sort_reverse" == "true" ]] && sort_key="-k1,1n" + else + # Name: ascending by default (A to Z) + sort_key="-k1,1f" + [[ "$sort_reverse" == "true" ]] && sort_key="-k1,1fr" + fi + + # Create temporary file for sorting + local tmpfile + tmpfile=$(mktemp 2>/dev/null) || tmpfile="" + if [[ -n "$tmpfile" ]]; then + local k id for id in "${filtered[@]}"; do case "$sort_mode" in - date) k="${epochs[id]:-${id}}" ;; + date) k="${epochs[id]:-0}" ;; size) k="${sizekb[id]:-0}" ;; name|*) k="${items[id]}|${id}" ;; esac printf "%s\t%s\n" "$k" "$id" >> "$tmpfile" done - fi - # Build sort key once and stream results into view_indices - local sort_key - if [[ "$sort_mode" == "date" || "$sort_mode" == "size" ]]; then - sort_key="-k1,1n" - [[ "$sort_reverse" == "true" ]] && sort_key="-k1,1nr" + view_indices=() + while IFS=$'\t' read -r _key _id; do + [[ -z "$_id" ]] && continue + view_indices+=("$_id") + done < <(LC_ALL=C sort -t $'\t' $sort_key -- "$tmpfile" 2>/dev/null) + + rm -f "$tmpfile" else - sort_key="-k1,1f" - [[ "$sort_reverse" == "true" ]] && sort_key="-k1,1fr" + # Fallback: no sorting + view_indices=("${filtered[@]}") fi - - view_indices=() - while IFS=$'\t' read -r _key _id; do - [[ -z "$_id" ]] && continue - view_indices+=("$_id") - done < <(LC_ALL=C sort -t $'\t' $sort_key -- "$tmpfile") - - rm -f "$tmpfile" - else - view_indices=("${filtered[@]}") fi # Clamp cursor into visible range @@ -334,38 +353,9 @@ paginated_multi_select() { [[ ${selected[i]} == true ]] && ((selected_count++)) done - # Header + # Header only printf "${clear_line}${PURPLE}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2 - # Sort + Filter status - local sort_label="" - case "$sort_mode" in - date) sort_label="Date" ;; - name) sort_label="Name" ;; - size) sort_label="Size" ;; - esac - local arrow="↑" - [[ "$sort_reverse" == "true" ]] && arrow="↓" - - local filter_label="" - if [[ "$filter_mode" == "true" ]]; then - filter_label="${YELLOW}${filter_query:-}${NC}${GRAY} [editing]${NC}" - else - if [[ -n "$applied_query" ]]; then - if [[ "$searching" == "true" ]]; then - filter_label="${GREEN}${applied_query}${NC}${GRAY} [searching…]${NC}" - else - filter_label="${GREEN}${applied_query}${NC}" - fi - else - filter_label="${GRAY}—${NC}" - fi - fi - printf "${clear_line}${GRAY}Sort:${NC} %s %s ${GRAY}|${NC} ${GRAY}Filter:${NC} %s\n" "$sort_label" "$arrow" "$filter_label" >&2 - - # Filter-mode hint line - if [[ "$filter_mode" == "true" ]]; then - printf "${clear_line}${GRAY}Tip:${NC} prefix with ${YELLOW}'${NC} to match from start\n" >&2 - fi + printf "${clear_line}\n" >&2 # Visible slice local visible_total=${#view_indices[@]} @@ -384,7 +374,7 @@ paginated_multi_select() { for ((i = 0; i < items_per_page + 2; i++)); do printf "${clear_line}\n" >&2 done - printf "${clear_line}${GRAY}${ICON_NAV_UP}/${ICON_NAV_DOWN}${NC} Navigate ${GRAY}|${NC} ${GRAY}Space${NC} Select ${GRAY}|${NC} ${GRAY}Enter${NC} Confirm ${GRAY}|${NC} ${GRAY}S/s${NC} Sort ${GRAY}|${NC} ${GRAY}R/r${NC} Reverse ${GRAY}|${NC} ${GRAY}F/f${NC} Filter ${GRAY}|${NC} ${GRAY}Q/ESC${NC} Quit\n" >&2 + printf "${clear_line}${GRAY}${ICON_NAV_UP}/${ICON_NAV_DOWN}${NC} Nav ${GRAY}|${NC} ${GRAY}Space${NC} Select ${GRAY}|${NC} ${GRAY}Enter${NC} Confirm ${GRAY}|${NC} ${GRAY}/${NC} Filter ${GRAY}|${NC} ${GRAY}S${NC} Sort ${GRAY}|${NC} ${GRAY}Q${NC} Quit\n" >&2 printf "${clear_line}" >&2 return else @@ -393,7 +383,7 @@ paginated_multi_select() { for ((i = 0; i < items_per_page + 2; i++)); do printf "${clear_line}\n" >&2 done - printf "${clear_line}${GRAY}${ICON_NAV_UP}/${ICON_NAV_DOWN}${NC} Navigate ${GRAY}|${NC} ${GRAY}Space${NC} Select ${GRAY}|${NC} ${GRAY}Enter${NC} Confirm ${GRAY}|${NC} ${GRAY}S${NC} Sort ${GRAY}|${NC} ${GRAY}R/r${NC} Reverse ${GRAY}|${NC} ${GRAY}F${NC} Filter ${GRAY}|${NC} ${GRAY}Q/ESC${NC} Quit\n" >&2 + printf "${clear_line}${GRAY}${ICON_NAV_UP}/${ICON_NAV_DOWN}${NC} Nav ${GRAY}|${NC} ${GRAY}Space${NC} Select ${GRAY}|${NC} ${GRAY}Enter${NC} Confirm ${GRAY}|${NC} ${GRAY}/${NC} Filter ${GRAY}|${NC} ${GRAY}S${NC} Sort ${GRAY}|${NC} ${GRAY}Q${NC} Quit\n" >&2 printf "${clear_line}" >&2 return fi @@ -430,55 +420,71 @@ paginated_multi_select() { done printf "${clear_line}\n" >&2 - # Footer with wrapped controls + + # Build sort and filter status + local sort_label="" + case "$sort_mode" in + date) sort_label="Date" ;; + name) sort_label="Name" ;; + size) sort_label="Size" ;; + esac + local arrow="↑" + [[ "$sort_reverse" == "true" ]] && arrow="↓" + local sort_status="${sort_label} ${arrow}" + + local filter_status="" + if [[ "$filter_mode" == "true" ]]; then + filter_status="${YELLOW}${filter_query:-}${NC}" + elif [[ -n "$applied_query" ]]; then + filter_status="${GREEN}${applied_query}${NC}" + else + filter_status="${GRAY}—${NC}" + fi + + # Footer with two lines: basic controls and advanced options local sep=" ${GRAY}|${NC} " if [[ "$filter_mode" == "true" ]]; then + # Filter mode: single line with all filter controls local -a _segs_filter=( - "${GRAY}Type to filter${NC}" - "${GRAY}Delete${NC} Backspace" + "${GRAY}Filter Input:${NC} ${filter_status}" + "${GRAY}Delete${NC} Back" "${GRAY}Enter${NC} Apply" + "${GRAY}/${NC} Clear" "${GRAY}ESC${NC} Cancel" ) _print_wrapped_controls "$sep" "${_segs_filter[@]}" else - local -a _segs_normal=( - "${GRAY}${ICON_NAV_UP}/${ICON_NAV_DOWN}${NC} Navigate" - "${GRAY}Space${NC} Select" - "${GRAY}Enter${NC} Confirm" - "${GRAY}S/s${NC} Sort" - "${GRAY}R/r${NC} Reverse" - "${GRAY}F/f${NC} Filter" - "${GRAY}A${NC} All" - "${GRAY}N${NC} None" - "${GRAY}Q/ESC${NC} Quit" - ) - _print_wrapped_controls "$sep" "${_segs_normal[@]}" + # Normal mode + if [[ "$has_metadata" == "true" ]]; then + # With metadata: two lines (basic + advanced) + local -a _segs_basic=( + "${GRAY}${ICON_NAV_UP}/${ICON_NAV_DOWN}${NC} Navigate" + "${GRAY}Space${NC} Select" + "${GRAY}Enter${NC} Confirm" + "${GRAY}Q/ESC${NC} Quit" + ) + _print_wrapped_controls "$sep" "${_segs_basic[@]}" + local -a _segs_advanced=( + "${GRAY}S${NC} ${sort_status}" + "${GRAY}R${NC} Reverse" + "${GRAY}/${NC} Filter" + ) + _print_wrapped_controls "$sep" "${_segs_advanced[@]}" + else + # Without metadata: single line (basic only) + local -a _segs_simple=( + "${GRAY}${ICON_NAV_UP}/${ICON_NAV_DOWN}${NC} Navigate" + "${GRAY}Space${NC} Select" + "${GRAY}Enter${NC} Confirm" + "${GRAY}/${NC} Filter" + "${GRAY}Q/ESC${NC} Quit" + ) + _print_wrapped_controls "$sep" "${_segs_simple[@]}" + fi fi printf "${clear_line}" >&2 } - # Show help screen - show_help() { - printf "\033[H\033[J" >&2 - cat >&2 << EOF -Help - Navigation Controls -========================== - - ${ICON_NAV_UP} / ${ICON_NAV_DOWN} Navigate up/down - Space Select/deselect item - Enter Confirm selection - S Change sort mode (Date / Name / Size) - R Reverse current sort (asc/desc) - F Toggle filter mode, type to filter (case-insensitive; prefix with ' to match from start) - A Select all (visible items) - N Deselect all (visible items) - Delete Backspace filter (in filter mode) - Q / ESC Exit (ESC exits filter mode first) - -Press any key to continue... -EOF - read -n 1 -s >&2 - } # Main interaction loop while true; do @@ -543,37 +549,23 @@ EOF fi fi ;; - "ALL") - # Select only currently visible (filtered) rows - if [[ ${#view_indices[@]} -gt 0 ]]; then - for real in "${view_indices[@]}"; do - selected[real]=true - done - fi - ;; - "NONE") - # Deselect only currently visible (filtered) rows - if [[ ${#view_indices[@]} -gt 0 ]]; then - for real in "${view_indices[@]}"; do - selected[real]=false - done - fi - ;; "RETRY") - # 'R' toggles reverse order - if [[ "$sort_reverse" == "true" ]]; then - sort_reverse="false" - else - sort_reverse="true" + # 'R' toggles reverse order (only if metadata available) + if [[ "$has_metadata" == "true" ]]; then + if [[ "$sort_reverse" == "true" ]]; then + sort_reverse="false" + else + sort_reverse="true" + fi + rebuild_view fi - rebuild_view ;; - "HELP") show_help ;; "CHAR:s"|"CHAR:S") if [[ "$filter_mode" == "true" ]]; then local ch="${key#CHAR:}" filter_query+="$ch" - else + elif [[ "$has_metadata" == "true" ]]; then + # Cycle sort mode (only if metadata available) case "$sort_mode" in date) sort_mode="name" ;; name) sort_mode="size" ;; @@ -582,16 +574,18 @@ EOF rebuild_view fi ;; + "FILTER") + # Trigger filter mode with / + filter_mode="true" + export MOLE_READ_KEY_FORCE_CHAR=1 + filter_query="" + top_index=0 + cursor_pos=0 + rebuild_view + ;; "CHAR:f"|"CHAR:F") if [[ "$filter_mode" == "true" ]]; then - filter_query+="f" - else - filter_mode="true" - export MOLE_READ_KEY_FORCE_CHAR=1 - filter_query="" # start empty -> 0 results - top_index=0 # reset viewport - cursor_pos=0 - rebuild_view + filter_query+="${key#CHAR:}" fi ;; "CHAR:r") @@ -616,8 +610,12 @@ EOF CHAR:*) if [[ "$filter_mode" == "true" ]]; then local ch="${key#CHAR:}" + # Special handling for /: clear filter + if [[ "$ch" == "/" ]]; then + filter_query="" + rebuild_view # avoid accidental leading spaces - if [[ -n "$filter_query" || "$ch" != " " ]]; then + elif [[ -n "$filter_query" || "$ch" != " " ]]; then filter_query+="$ch" fi fi @@ -637,6 +635,7 @@ EOF draw_menu continue fi + # In normal mode: confirm and exit with current selections local -a selected_indices=() for ((i = 0; i < total_items; i++)); do if [[ ${selected[i]} == true ]]; then @@ -644,24 +643,20 @@ EOF fi done - # Allow empty selection - don't auto-select cursor position local final_result="" if [[ ${#selected_indices[@]} -gt 0 ]]; then local IFS=',' final_result="${selected_indices[*]}" fi - # Remove the trap to avoid cleanup on normal exit trap - EXIT INT TERM - - # Store result in global variable MOLE_SELECTION_RESULT="$final_result" - - # Manually cleanup terminal before returning restore_terminal - return 0 ;; + "HELP") + # Removed help screen, users can explore the interface + ;; esac done } diff --git a/lib/simple_menu.sh b/lib/simple_menu.sh new file mode 100755 index 0000000..4496a6b --- /dev/null +++ b/lib/simple_menu.sh @@ -0,0 +1,292 @@ +#!/bin/bash +# Paginated menu with arrow key navigation + +set -euo pipefail + +# Terminal control functions +enter_alt_screen() { tput smcup 2> /dev/null || true; } +leave_alt_screen() { tput rmcup 2> /dev/null || true; } + +# Main paginated multi-select menu function +paginated_multi_select() { + local title="$1" + shift + local -a items=("$@") + local external_alt_screen=false + if [[ "${MOLE_MANAGED_ALT_SCREEN:-}" == "1" || "${MOLE_MANAGED_ALT_SCREEN:-}" == "true" ]]; then + external_alt_screen=true + fi + + # Validation + if [[ ${#items[@]} -eq 0 ]]; then + echo "No items provided" >&2 + return 1 + fi + + local total_items=${#items[@]} + local items_per_page=15 + local cursor_pos=0 + local top_index=0 + local -a selected=() + + # Initialize selection array + for ((i = 0; i < total_items; i++)); do + selected[i]=false + done + + if [[ -n "${MOLE_PRESELECTED_INDICES:-}" ]]; then + local cleaned_preselect="${MOLE_PRESELECTED_INDICES//[[:space:]]/}" + local -a initial_indices=() + IFS=',' read -ra initial_indices <<< "$cleaned_preselect" + for idx in "${initial_indices[@]}"; do + if [[ "$idx" =~ ^[0-9]+$ && $idx -ge 0 && $idx -lt $total_items ]]; then + selected[idx]=true + fi + done + fi + + # Preserve original TTY settings so we can restore them reliably + local original_stty="" + if [[ -t 0 ]] && command -v stty > /dev/null 2>&1; then + original_stty=$(stty -g 2> /dev/null || echo "") + fi + + restore_terminal() { + show_cursor + if [[ -n "${original_stty-}" ]]; then + stty "${original_stty}" 2> /dev/null || stty sane 2> /dev/null || stty echo icanon 2> /dev/null || true + else + stty sane 2> /dev/null || stty echo icanon 2> /dev/null || true + fi + if [[ "${external_alt_screen:-false}" == false ]]; then + leave_alt_screen + fi + } + + # Cleanup function + cleanup() { + trap - EXIT INT TERM + restore_terminal + } + + # Interrupt handler + handle_interrupt() { + cleanup + exit 130 # Standard exit code for Ctrl+C + } + + trap cleanup EXIT + trap handle_interrupt INT TERM + + # Setup terminal - preserve interrupt character + stty -echo -icanon intr ^C 2> /dev/null || true + if [[ $external_alt_screen == false ]]; then + enter_alt_screen + # Clear screen once on entry to alt screen + printf "\033[2J\033[H" >&2 + else + printf "\033[H" >&2 + fi + hide_cursor + + # Helper functions + print_line() { printf "\r\033[2K%s\n" "$1" >&2; } + + render_item() { + local idx=$1 is_current=$2 + local checkbox="$ICON_EMPTY" + [[ ${selected[idx]} == true ]] && checkbox="$ICON_SOLID" + + if [[ $is_current == true ]]; then + printf "\r\033[2K${BLUE}${ICON_ARROW} %s %s${NC}\n" "$checkbox" "${items[idx]}" >&2 + else + printf "\r\033[2K %s %s\n" "$checkbox" "${items[idx]}" >&2 + fi + } + + # Draw the complete menu + draw_menu() { + # Move to home position without clearing (reduces flicker) + printf "\033[H" >&2 + + # Clear each line as we go instead of clearing entire screen + local clear_line="\r\033[2K" + + # Count selections for header display + local selected_count=0 + for ((i = 0; i < total_items; i++)); do + [[ ${selected[i]} == true ]] && ((selected_count++)) + done + + # Header + printf "${clear_line}${PURPLE}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2 + + if [[ $total_items -eq 0 ]]; then + printf "${clear_line}${GRAY}No items available${NC}\n" >&2 + printf "${clear_line}\n" >&2 + printf "${clear_line}${GRAY}Q/ESC${NC} Quit\n" >&2 + printf "${clear_line}" >&2 + return + fi + + if [[ $top_index -gt $((total_items - 1)) ]]; then + if [[ $total_items -gt $items_per_page ]]; then + top_index=$((total_items - items_per_page)) + else + top_index=0 + fi + fi + + local visible_count=$((total_items - top_index)) + [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page + [[ $visible_count -le 0 ]] && visible_count=1 + if [[ $cursor_pos -ge $visible_count ]]; then + cursor_pos=$((visible_count - 1)) + [[ $cursor_pos -lt 0 ]] && cursor_pos=0 + fi + + printf "${clear_line}\n" >&2 + + # Items for current window + local start_idx=$top_index + local end_idx=$((top_index + items_per_page - 1)) + [[ $end_idx -ge $total_items ]] && end_idx=$((total_items - 1)) + + for ((i = start_idx; i <= end_idx; i++)); do + [[ $i -lt 0 ]] && continue + local is_current=false + [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true + render_item $i $is_current + done + + # Fill empty slots to clear previous content + local items_shown=$((end_idx - start_idx + 1)) + [[ $items_shown -lt 0 ]] && items_shown=0 + for ((i = items_shown; i < items_per_page; i++)); do + printf "${clear_line}\n" >&2 + done + + # Clear any remaining lines at bottom + printf "${clear_line}\n" >&2 + printf "${clear_line}${GRAY}${ICON_NAV_UP}/${ICON_NAV_DOWN}${NC} Navigate ${GRAY}|${NC} ${GRAY}Space${NC} Select ${GRAY}|${NC} ${GRAY}Enter${NC} Confirm ${GRAY}|${NC} ${GRAY}Q/ESC${NC} Quit\n" >&2 + + # Clear one more line to ensure no artifacts + printf "${clear_line}" >&2 + } + + # Show help screen + show_help() { + printf "\033[H\033[J" >&2 + cat >&2 << EOF +Help - Navigation Controls +========================== + + ${ICON_NAV_UP} / ${ICON_NAV_DOWN} Navigate up/down + Space Select/deselect item + Enter Confirm selection + Q / ESC Exit + +Press any key to continue... +EOF + read -n 1 -s >&2 + } + + # Main interaction loop + while true; do + draw_menu + local key=$(read_key) + + case "$key" in + "QUIT") + cleanup + return 1 + ;; + "UP") + if [[ $total_items -eq 0 ]]; then + : + elif [[ $cursor_pos -gt 0 ]]; then + ((cursor_pos--)) + elif [[ $top_index -gt 0 ]]; then + ((top_index--)) + fi + ;; + "DOWN") + if [[ $total_items -eq 0 ]]; then + : + else + local absolute_index=$((top_index + cursor_pos)) + if [[ $absolute_index -lt $((total_items - 1)) ]]; 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++)) + visible_count=$((total_items - top_index)) + [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page + if [[ $cursor_pos -ge $visible_count ]]; then + cursor_pos=$((visible_count - 1)) + fi + fi + fi + fi + ;; + "SPACE") + local idx=$((top_index + cursor_pos)) + if [[ $idx -lt $total_items ]]; then + if [[ ${selected[idx]} == true ]]; then + selected[idx]=false + else + selected[idx]=true + fi + fi + ;; + "ALL") + for ((i = 0; i < total_items; i++)); do + selected[i]=true + done + ;; + "NONE") + for ((i = 0; i < total_items; i++)); do + selected[i]=false + done + ;; + "HELP") show_help ;; + "ENTER") + # Store result in global variable instead of returning via stdout + local -a selected_indices=() + for ((i = 0; i < total_items; i++)); do + if [[ ${selected[i]} == true ]]; then + selected_indices+=("$i") + fi + done + + # Allow empty selection - don't auto-select cursor position + # This fixes the bug where unselecting all items would still select the last cursor position + local final_result="" + if [[ ${#selected_indices[@]} -gt 0 ]]; then + local IFS=',' + final_result="${selected_indices[*]}" + fi + + # Remove the trap to avoid cleanup on normal exit + trap - EXIT INT TERM + + # Store result in global variable + MOLE_SELECTION_RESULT="$final_result" + + # Manually cleanup terminal before returning + restore_terminal + + return 0 + ;; + esac + done +} + +# Export function for external use +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + echo "This is a library file. Source it from other scripts." >&2 + exit 1 +fi diff --git a/lib/whitelist_manager.sh b/lib/whitelist_manager.sh index 6cb2fcc..827bef7 100755 --- a/lib/whitelist_manager.sh +++ b/lib/whitelist_manager.sh @@ -7,7 +7,7 @@ set -euo pipefail # Get script directory and source dependencies SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/common.sh" -source "$SCRIPT_DIR/paginated_menu.sh" +source "$SCRIPT_DIR/simple_menu.sh" # Config file path WHITELIST_CONFIG="$HOME/.config/mole/whitelist" From 5ec2ca0c465152e83a2fb04704aa4a8839833e26 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Tue, 14 Oct 2025 17:27:38 +0800 Subject: [PATCH 04/17] Fix whitelist issue caused by retrieval list --- lib/common.sh | 6 ++-- lib/paginated_menu.sh | 70 ++++++++++++++++++++++--------------------- 2 files changed, 39 insertions(+), 37 deletions(-) diff --git a/lib/common.sh b/lib/common.sh index fc5afb2..740067b 100755 --- a/lib/common.sh +++ b/lib/common.sh @@ -186,9 +186,9 @@ read_key() { return 0 fi case "$key" in - $'\n'|$'\r') echo "ENTER" ;; - $'\x7f'|$'\x08') echo "DELETE" ;; - $'\x1b') echo "QUIT" ;; # ESC cancels filter + $'\n' | $'\r') echo "ENTER" ;; + $'\x7f' | $'\x08') echo "DELETE" ;; + $'\x1b') echo "QUIT" ;; # ESC cancels filter *) case "$key" in [[:print:]]) echo "CHAR:$key" ;; diff --git a/lib/paginated_menu.sh b/lib/paginated_menu.sh index a52283b..95630c9 100755 --- a/lib/paginated_menu.sh +++ b/lib/paginated_menu.sh @@ -29,11 +29,11 @@ _pm_parse_csv_to_array() { # Non-blocking input drain (bash 3.2) drain_pending_input() { - local _k - # -t 0 is non-blocking; -n 1 consumes one byte at a time - while IFS= read -r -s -n 1 -t 0 _k; do - IFS= read -r -s -n 1 _k || break - done + local _k + # -t 0 is non-blocking; -n 1 consumes one byte at a time + while IFS= read -r -s -n 1 -t 0 _k; do + IFS= read -r -s -n 1 _k || break + done } # Main paginated multi-select menu function @@ -57,8 +57,8 @@ paginated_multi_select() { local cursor_pos=0 local top_index=0 local filter_query="" - local filter_mode="false" # filter mode toggle - local sort_mode="${MOLE_MENU_SORT_DEFAULT:-date}" # date|name|size + local filter_mode="false" # filter mode toggle + local sort_mode="${MOLE_MENU_SORT_DEFAULT:-date}" # date|name|size local sort_reverse="false" # Live query vs applied query local applied_query="" @@ -97,11 +97,11 @@ paginated_multi_select() { _pm_escape_glob() { local s="${1-}" out="" c local i len=${#s} - for ((i=0; i/dev/null || echo 80) + [[ -z "$cols" ]] && cols=$(tput cols 2> /dev/null || echo 80) _strip_ansi_len() { local text="$1" @@ -212,7 +213,7 @@ paginated_multi_select() { else candidate="$line${sep}${s}" fi - if (( $(_strip_ansi_len "$candidate") > cols )); then + if (($(_strip_ansi_len "$candidate") > cols)); then printf "%s%s\n" "$clear_line" "$line" >&2 line="$s" else @@ -280,14 +281,14 @@ paginated_multi_select() { # Create temporary file for sorting local tmpfile - tmpfile=$(mktemp 2>/dev/null) || tmpfile="" + tmpfile=$(mktemp 2> /dev/null) || tmpfile="" if [[ -n "$tmpfile" ]]; then local k id for id in "${filtered[@]}"; do case "$sort_mode" in date) k="${epochs[id]:-0}" ;; size) k="${sizekb[id]:-0}" ;; - name|*) k="${items[id]}|${id}" ;; + name | *) k="${items[id]}|${id}" ;; esac printf "%s\t%s\n" "$k" "$id" >> "$tmpfile" done @@ -296,7 +297,7 @@ paginated_multi_select() { while IFS=$'\t' read -r _key _id; do [[ -z "$_id" ]] && continue view_indices+=("$_id") - done < <(LC_ALL=C sort -t $'\t' $sort_key -- "$tmpfile" 2>/dev/null) + done < <(LC_ALL=C sort -t $'\t' $sort_key -- "$tmpfile" 2> /dev/null) rm -f "$tmpfile" else @@ -485,7 +486,6 @@ paginated_multi_select() { printf "${clear_line}" >&2 } - # Main interaction loop while true; do draw_menu @@ -494,18 +494,19 @@ paginated_multi_select() { case "$key" in "QUIT") - if [[ "$filter_mode" == "true" ]]; then - filter_mode="false" - unset MOLE_READ_KEY_FORCE_CHAR - filter_query="" - applied_query="" - top_index=0; cursor_pos=0 - rebuild_view - continue - fi - cleanup - return 1 - ;; + if [[ "$filter_mode" == "true" ]]; then + filter_mode="false" + unset MOLE_READ_KEY_FORCE_CHAR + filter_query="" + applied_query="" + top_index=0 + cursor_pos=0 + rebuild_view + continue + fi + cleanup + return 1 + ;; "UP") if [[ ${#view_indices[@]} -eq 0 ]]; then : @@ -560,7 +561,7 @@ paginated_multi_select() { rebuild_view fi ;; - "CHAR:s"|"CHAR:S") + "CHAR:s" | "CHAR:S") if [[ "$filter_mode" == "true" ]]; then local ch="${key#CHAR:}" filter_query+="$ch" @@ -583,7 +584,7 @@ paginated_multi_select() { cursor_pos=0 rebuild_view ;; - "CHAR:f"|"CHAR:F") + "CHAR:f" | "CHAR:F") if [[ "$filter_mode" == "true" ]]; then filter_query+="${key#CHAR:}" fi @@ -625,11 +626,12 @@ paginated_multi_select() { applied_query="$filter_query" filter_mode="false" unset MOLE_READ_KEY_FORCE_CHAR - top_index=0; cursor_pos=0 + top_index=0 + cursor_pos=0 searching="true" - draw_menu # paint "searching..." - drain_pending_input # drop any extra keypresses (e.g., double-Enter) + draw_menu # paint "searching..." + drain_pending_input # drop any extra keypresses (e.g., double-Enter) rebuild_view searching="false" draw_menu From 05e3ec78dc3be2eb93df3db577b247e489b25c8f Mon Sep 17 00:00:00 2001 From: Tw93 Date: Tue, 14 Oct 2025 19:18:03 +0800 Subject: [PATCH 05/17] Enter to select the next step --- lib/common.sh | 13 +++++++++++-- lib/paginated_menu.sh | 21 ++++++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/lib/common.sh b/lib/common.sh index 740067b..f75ed9c 100755 --- a/lib/common.sh +++ b/lib/common.sh @@ -175,8 +175,17 @@ show_cursor() { # Keyboard input handling (simple and robust) read_key() { - local key rest - IFS= read -r -s -n 1 key || return 1 + local key rest read_status + + # Read with explicit status check + IFS= read -r -s -n 1 key + read_status=$? + + # Handle read failure (Ctrl+D, EOF, etc.) - treat as quit + if [[ $read_status -ne 0 ]]; then + echo "QUIT" + return 0 + fi # Raw typing mode (filter): map most keys to CHAR: if [[ "${MOLE_READ_KEY_FORCE_CHAR:-}" == "1" ]]; then diff --git a/lib/paginated_menu.sh b/lib/paginated_menu.sh index 95630c9..011d133 100755 --- a/lib/paginated_menu.sh +++ b/lib/paginated_menu.sh @@ -637,7 +637,26 @@ paginated_multi_select() { draw_menu continue fi - # In normal mode: confirm and exit with current selections + # In normal mode: smart Enter behavior + # 1. Check if any items are already selected + local has_selection=false + for ((i = 0; i < total_items; i++)); do + if [[ ${selected[i]} == true ]]; then + has_selection=true + break + fi + done + + # 2. If nothing selected, auto-select current item + if [[ $has_selection == false ]]; then + local idx=$((top_index + cursor_pos)) + if [[ $idx -lt ${#view_indices[@]} ]]; then + local real="${view_indices[idx]}" + selected[real]=true + fi + fi + + # 3. Confirm and exit with current selections local -a selected_indices=() for ((i = 0; i < total_items; i++)); do if [[ ${selected[i]} == true ]]; then From 35c4db2b81d102463c382304c41224321e464e08 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Tue, 14 Oct 2025 19:43:59 +0800 Subject: [PATCH 06/17] Delete useless help and optimize format --- .github/workflows/tests.yml | 3 +++ bin/analyze.sh | 44 ------------------------------------- bin/clean.sh | 15 +------------ bin/touchid.sh | 32 --------------------------- bin/uninstall.sh | 34 ---------------------------- lib/paginated_menu.sh | 1 - 6 files changed, 4 insertions(+), 125 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2a155bd..8444494 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,5 +16,8 @@ jobs: - name: Install tools run: brew install bats-core shfmt shellcheck + - name: Format code + run: ./scripts/format.sh + - name: Run all checks run: ./scripts/check.sh diff --git a/bin/analyze.sh b/bin/analyze.sh index 75befc5..cd2cdf2 100755 --- a/bin/analyze.sh +++ b/bin/analyze.sh @@ -2392,50 +2392,6 @@ export_to_json() { main() { local target_path="$HOME" - # Parse arguments - only support --help - while [[ $# -gt 0 ]]; do - case "$1" in - -h | --help) - echo "Usage: mole analyze" - echo "" - echo "Interactive disk space explorer - Navigate folders sorted by size" - echo "" - echo "Keyboard Controls:" - echo " ${ICON_NAV_UP}/${ICON_NAV_DOWN} Navigate items" - echo " Enter / ${ICON_NAV_RIGHT} Open selected folder" - echo " ${ICON_NAV_LEFT} Go back to parent directory" - echo " Delete Delete selected file/folder (requires confirmation)" - echo " O Reveal current directory in Finder" - echo " Q / ESC Quit the explorer" - echo "" - echo "Features:" - echo " ${ICON_LIST} Files and folders sorted by size (largest first)" - echo " ${ICON_LIST} Shows top 16 items per directory" - echo " ${ICON_LIST} Fast parallel scanning with smart timeout" - echo " ${ICON_LIST} Session cache for instant navigation" - echo " ${ICON_LIST} Color coding for large folders (Red >10GB, Yellow >1GB)" - echo " ${ICON_LIST} Safe deletion with confirmation" - echo "" - echo "Examples:" - echo " mole analyze Start exploring from home directory" - echo "" - exit 0 - ;; - -*) - echo "Error: Unknown option: $1" >&2 - echo "Usage: mole analyze" >&2 - echo "Use 'mole analyze --help' for more information" >&2 - exit 1 - ;; - *) - echo "Error: Paths are not supported in beta version" >&2 - echo "Usage: mole analyze" >&2 - echo "The explorer will start from your home directory" >&2 - exit 1 - ;; - esac - done - CURRENT_PATH="$target_path" # Create cache directory diff --git a/bin/clean.sh b/bin/clean.sh index cb27aa1..0749d4a 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -1412,7 +1412,7 @@ perform_cleanup() { } main() { - # Parse args (only dry-run and help for minimal impact) + # Parse args (only dry-run and whitelist) for arg in "$@"; do case "$arg" in "--dry-run" | "-n") @@ -1423,19 +1423,6 @@ main() { manage_whitelist exit 0 ;; - "--help" | "-h") - echo "Mole - Deeper system cleanup" - echo "Usage: clean.sh [options]" - echo "" - echo "Options:" - echo " --help, -h Show this help" - echo " --dry-run, -n Preview what would be cleaned without deleting" - echo " --whitelist Manage protected caches" - echo "" - echo "Interactive cleanup with smart password handling" - echo "" - exit 0 - ;; esac done diff --git a/bin/touchid.sh b/bin/touchid.sh index a882460..7a7ab5b 100755 --- a/bin/touchid.sh +++ b/bin/touchid.sh @@ -137,33 +137,6 @@ disable_touchid() { fi } -# Show help -show_help() { - cat << EOF -Usage: mo touchid [command] - -Configure Touch ID for sudo to avoid repeated password prompts. - -Commands: - enable Enable Touch ID for sudo - disable Disable Touch ID for sudo - status Show current Touch ID configuration - help Show this help message - -Examples: - mo touchid # Show status and options - mo touchid enable # Enable Touch ID - mo touchid status # Check current status - -Notes: - - Requires macOS with Touch ID sensor - - Changes are applied to /etc/pam.d/sudo - - Automatic backup is created before changes - - You can restore from backup at ${PAM_SUDO_FILE}.mole-backup - -EOF -} - # Interactive menu show_menu() { echo "" @@ -220,16 +193,11 @@ main() { status) show_status ;; - help | --help | -h) - show_help - ;; "") show_menu ;; *) log_error "Unknown command: $command" - echo "" - show_help exit 1 ;; esac diff --git a/bin/uninstall.sh b/bin/uninstall.sh index 135c8a4..f77fca5 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -21,40 +21,6 @@ source "$SCRIPT_DIR/../lib/batch_uninstall.sh" # Note: Bundle preservation logic is now in lib/common.sh -# Help information -show_help() { - echo "Usage: mole uninstall" - echo "" - echo "Interactive application uninstaller - Remove apps completely" - echo "" - echo "Keyboard Controls:" - echo " ${ICON_NAV_UP}/${ICON_NAV_DOWN} Navigate items" - echo " Space Select/deselect" - echo " Enter Confirm and uninstall" - echo " Q / ESC Quit" - echo "" - echo "What gets cleaned:" - echo " ${ICON_LIST} Application bundle" - echo " ${ICON_LIST} Application Support data (12+ locations)" - echo " ${ICON_LIST} Cache files" - echo " ${ICON_LIST} Preference files" - echo " ${ICON_LIST} Log files" - echo " ${ICON_LIST} Saved application state" - echo " ${ICON_LIST} Container data (sandboxed apps)" - echo " ${ICON_LIST} WebKit storage, HTTP storage, cookies" - echo " ${ICON_LIST} Extensions, plugins, services" - echo "" - echo "Examples:" - echo " mole uninstall Launch interactive uninstaller" - echo "" -} - -# Parse arguments -if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then - show_help - exit 0 -fi - # Initialize global variables selected_apps=() # Global array for app selection declare -a apps_data=() diff --git a/lib/paginated_menu.sh b/lib/paginated_menu.sh index 011d133..31eb40e 100755 --- a/lib/paginated_menu.sh +++ b/lib/paginated_menu.sh @@ -356,7 +356,6 @@ paginated_multi_select() { # Header only printf "${clear_line}${PURPLE}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2 - printf "${clear_line}\n" >&2 # Visible slice local visible_total=${#view_indices[@]} From 2557c5f209af02ce5beea40abd9c487103a9b55d Mon Sep 17 00:00:00 2001 From: Tw93 Date: Tue, 14 Oct 2025 20:06:52 +0800 Subject: [PATCH 07/17] Uninstall List Cache --- bin/uninstall.sh | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/bin/uninstall.sh b/bin/uninstall.sh index f77fca5..829af84 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -66,7 +66,7 @@ scan_applications() { local cache_dir="$HOME/.cache/mole" local cache_file="$cache_dir/app_scan_cache" local cache_meta="$cache_dir/app_scan_meta" - local cache_ttl=3600 # 1 hour cache validity + local cache_ttl=86400 # 24 hours cache validity (app count change will trigger refresh) mkdir -p "$cache_dir" 2> /dev/null @@ -87,12 +87,14 @@ scan_applications() { # Cache is valid if: age < TTL AND app count matches if [[ $cache_age -lt $cache_ttl && "$cached_app_count" == "$current_app_count" ]]; then - # Silent - cache hit, no need to show progress + # Silent - cache hit, return immediately without any output echo "$cache_file" return 0 fi fi + # Cache miss - show scanning feedback below + local temp_file temp_file=$(create_temp_file) @@ -541,7 +543,24 @@ main() { # Hide cursor during operation hide_cursor - if [[ $use_inline_loading == true ]]; then + # Quick cache validity check first (minimal I/O) + local cache_dir="$HOME/.cache/mole" + local cache_file="$cache_dir/app_scan_cache" + local cache_meta="$cache_dir/app_scan_meta" + local cache_ttl=86400 + local needs_scanning=true + + # Fast preliminary check: cache exists and not expired + if [[ -f "$cache_file" && -f "$cache_meta" ]]; then + local cache_age=$(($(date +%s) - $(stat -f%m "$cache_file" 2> /dev/null || echo 86401))) + if [[ $cache_age -lt $cache_ttl ]]; then + # Cache age is OK, now check app count (delegate to scan_applications) + needs_scanning=false + fi + fi + + # Only enter alt screen if we need scanning (shows progress) + if [[ $needs_scanning == true && $use_inline_loading == true ]]; then enter_alt_screen export MOLE_ALT_SCREEN_ACTIVE=1 export MOLE_INLINE_LOADING=1 @@ -554,7 +573,7 @@ main() { # Scan applications local apps_file="" if ! apps_file=$(scan_applications); then - if [[ $use_inline_loading == true ]]; then + if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then printf "\033[2J\033[H" >&2 leave_alt_screen unset MOLE_ALT_SCREEN_ACTIVE @@ -563,13 +582,13 @@ main() { return 1 fi - if [[ $use_inline_loading == true ]]; then + if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then printf "\033[2J\033[H" >&2 fi if [[ ! -f "$apps_file" ]]; then # Error message already shown by scan_applications - if [[ $use_inline_loading == true ]]; then + if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then leave_alt_screen unset MOLE_ALT_SCREEN_ACTIVE unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN @@ -579,7 +598,7 @@ main() { # Load applications if ! load_applications "$apps_file"; then - if [[ $use_inline_loading == true ]]; then + if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then leave_alt_screen unset MOLE_ALT_SCREEN_ACTIVE unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN @@ -590,7 +609,7 @@ main() { # Interactive selection using paginated menu if ! select_apps_for_uninstall; then - if [[ $use_inline_loading == true ]]; then + if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then leave_alt_screen unset MOLE_ALT_SCREEN_ACTIVE unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN @@ -599,7 +618,7 @@ main() { return 0 fi - if [[ $use_inline_loading == true ]]; then + if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then leave_alt_screen unset MOLE_ALT_SCREEN_ACTIVE unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN From ffe2f458e1c8ef41499577d351fcbeb5bae502c7 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Tue, 14 Oct 2025 20:11:48 +0800 Subject: [PATCH 08/17] 1.7.11 --- mole | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mole b/mole index d9b479b..827a369 100755 --- a/mole +++ b/mole @@ -22,7 +22,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/lib/common.sh" # Version info -VERSION="1.7.10" +VERSION="1.7.11" MOLE_TAGLINE="can dig deep to clean your Mac." # Get latest version from remote repository From 305e53e4f19607fa6c8606f0ce279742f8978bed Mon Sep 17 00:00:00 2001 From: Tw93 Date: Tue, 14 Oct 2025 20:19:34 +0800 Subject: [PATCH 09/17] update docs --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 00ef708..f80cfb3 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@

Mole - 95.50GB freed +

由于 Mole 还在初级版本,如果这台 Mac 对你非常重要,建议再等等。

## Features @@ -56,7 +57,6 @@ mo --version # Show installed version ## Tips -- 安全第一,如果这台 Mac 对你非常重要,建议等 Mole 更成熟时再使用。 - Safety first, if your Mac is mission-critical, wait for Mole to mature before full cleanups. - Preview the cleanup by running `mo clean --dry-run` and reviewing the generated list. - Protect caches with `mo clean --whitelist`; defaults cover Playwright, HuggingFace, and Maven paths. From 43ecd50d14174ca01d5706ee87c9e4dbcacceda9 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Tue, 14 Oct 2025 20:19:54 +0800 Subject: [PATCH 10/17] update docs --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f80cfb3..7ba0ed5 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

Mole - 95.50GB freed -

由于 Mole 还在初级版本,如果这台 Mac 对你非常重要,建议再等等。

+

由于 Mole 还在初级版本,如果这台 Mac 对你非常重要,建议再等等。

## Features From 481201d302331656dcc2178dcb285a6b2b12a566 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Tue, 14 Oct 2025 20:39:04 +0800 Subject: [PATCH 11/17] Update README.md --- README.md | 37 ------------------------------------- 1 file changed, 37 deletions(-) diff --git a/README.md b/README.md index 7ba0ed5..1b89b7f 100644 --- a/README.md +++ b/README.md @@ -141,43 +141,6 @@ Total: 156.8GB └─ 📁 Desktop 12.7GB ``` -## Development - -### Setup - -Install development tools: - -```bash -brew install shfmt shellcheck bats-core -``` - -### Code Quality - -Format and lint shell scripts: - -```bash -# Format all scripts -./scripts/format.sh - -# Check without modifying -./scripts/format.sh --check - -# Install git hooks for auto-formatting -./scripts/install-hooks.sh -``` - -See [scripts/README.md](scripts/README.md) for detailed development workflow. - -### Testing - -Run automated tests: - -```bash -./tests/run.sh -``` - -GitHub Actions automatically runs tests and formatting checks on PRs. - ## Support - If Mole reclaimed storage for you, consider starring the repo or sharing it with friends needing a cleaner Mac. From e98f0baac40ba69506cc493a342cd5c9db703c95 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Wed, 15 Oct 2025 09:49:20 +0800 Subject: [PATCH 12/17] update tests --- tests/cli.bats | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/tests/cli.bats b/tests/cli.bats index 820a8a3..ac0d910 100644 --- a/tests/cli.bats +++ b/tests/cli.bats @@ -45,34 +45,6 @@ setup() { [[ "$output" == *"Unknown command: unknown-command"* ]] } -@test "clean.sh --help shows usage details" { - run env HOME="$HOME" "$PROJECT_ROOT/bin/clean.sh" --help - [ "$status" -eq 0 ] - [[ "$output" == *"Mole - Deeper system cleanup"* ]] - [[ "$output" == *"--dry-run"* ]] -} - -@test "uninstall.sh --help highlights controls" { - run env HOME="$HOME" "$PROJECT_ROOT/bin/uninstall.sh" --help - [ "$status" -eq 0 ] - [[ "$output" == *"Usage: mole uninstall"* ]] - [[ "$output" == *"Keyboard Controls"* ]] -} - -@test "analyze.sh --help outlines explorer features" { - run env HOME="$HOME" "$PROJECT_ROOT/bin/analyze.sh" --help - [ "$status" -eq 0 ] - [[ "$output" == *"Interactive disk space explorer"* ]] - [[ "$output" == *"mole analyze"* ]] -} - -@test "touchid --help describes configuration options" { - run env HOME="$HOME" "$PROJECT_ROOT/mole" touchid --help - [ "$status" -eq 0 ] - [[ "$output" == *"Touch ID"* ]] - [[ "$output" == *"mo touchid enable"* ]] -} - @test "touchid status reports current configuration" { run env HOME="$HOME" "$PROJECT_ROOT/mole" touchid status [ "$status" -eq 0 ] From b2c8feacde36501e56715a11bf072e8244b2c495 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Wed, 15 Oct 2025 09:56:37 +0800 Subject: [PATCH 13/17] update tests --- .github/workflows/tests.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8444494..252a6ec 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,18 +6,18 @@ on: pull_request: jobs: - test: + shell-quality-checks: runs-on: macos-latest steps: - - name: Checkout repository + - name: Checkout source code uses: actions/checkout@v4 - - name: Install tools + - name: Install shell linting and testing tools run: brew install bats-core shfmt shellcheck - - name: Format code + - name: Auto-format shell scripts with shfmt run: ./scripts/format.sh - - name: Run all checks + - name: Run shellcheck linter and bats tests run: ./scripts/check.sh From 92d3410e245c2ef4c43e908099eb40681719adc1 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Wed, 15 Oct 2025 10:00:15 +0800 Subject: [PATCH 14/17] update tests --- .github/workflows/{tests.yml => shell-quality-checks.yml} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename .github/workflows/{tests.yml => shell-quality-checks.yml} (93%) diff --git a/.github/workflows/tests.yml b/.github/workflows/shell-quality-checks.yml similarity index 93% rename from .github/workflows/tests.yml rename to .github/workflows/shell-quality-checks.yml index 252a6ec..f09210e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/shell-quality-checks.yml @@ -1,4 +1,4 @@ -name: Tests +name: Shell Script Quality Checks on: push: From 1657aa0e35aa41045b83d0762556371fb3a86a66 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Wed, 15 Oct 2025 10:27:04 +0800 Subject: [PATCH 15/17] 1.7.12 --- mole | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mole b/mole index 827a369..9461fce 100755 --- a/mole +++ b/mole @@ -22,7 +22,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/lib/common.sh" # Version info -VERSION="1.7.11" +VERSION="1.7.12" MOLE_TAGLINE="can dig deep to clean your Mac." # Get latest version from remote repository From cb442f5065904843e2aa9de9ce91089a39d90444 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Wed, 15 Oct 2025 15:47:07 +0800 Subject: [PATCH 16/17] 1.7.14 --- mole | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mole b/mole index 9461fce..0f55d71 100755 --- a/mole +++ b/mole @@ -22,7 +22,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/lib/common.sh" # Version info -VERSION="1.7.12" +VERSION="1.7.14" MOLE_TAGLINE="can dig deep to clean your Mac." # Get latest version from remote repository From 419df94f00bf6b6d5e76758ac8b5b3a3f8356448 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Wed, 15 Oct 2025 19:23:13 +0800 Subject: [PATCH 17/17] Fix remove permission issue --- mole | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/mole b/mole index 0f55d71..fe0f566 100755 --- a/mole +++ b/mole @@ -22,7 +22,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/lib/common.sh" # Version info -VERSION="1.7.14" +VERSION="1.7.15" MOLE_TAGLINE="can dig deep to clean your Mac." # Get latest version from remote repository @@ -366,18 +366,34 @@ remove_mole() { has_error=true fi fi - # Remove manual installations (silent) + # Remove manual installations if [[ ${manual_count:-0} -gt 0 ]]; then for install in "${manual_installs[@]}"; do if [[ -f "$install" ]]; then - rm -f "$install" 2> /dev/null || has_error=true + # Check if directory requires sudo (deletion is a directory operation) + if [[ ! -w "$(dirname "$install")" ]]; then + # Requires sudo + if ! sudo rm -f "$install" 2> /dev/null; then + has_error=true + fi + else + # Regular user permission + if ! rm -f "$install" 2> /dev/null; then + has_error=true + fi + fi fi done fi if [[ ${alias_count:-0} -gt 0 ]]; then for alias in "${alias_installs[@]}"; do if [[ -f "$alias" ]]; then - rm -f "$alias" 2> /dev/null || true + # Check if directory requires sudo + if [[ ! -w "$(dirname "$alias")" ]]; then + sudo rm -f "$alias" 2> /dev/null || true + else + rm -f "$alias" 2> /dev/null || true + fi fi done fi