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 还在初级版本,如果这台 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 还在初级版本,如果这台 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