1
0
mirror of https://github.com/tw93/Mole.git synced 2026-03-22 16:45:07 +00:00

feat(purge): add confirm dialog, two-pass column alignment, adaptive footer

- Add confirm_purge_cleanup() to show item count + size and require
  explicit Enter/y confirmation before any deletion
- Two-pass layout in clean_project_artifacts: pass 1 collects data,
  pre-scan finds max path and artifact widths, pass 2 formats with
  consistent column alignment across all rows
- Adaptive footer hints in select_purge_categories degrade gracefully
  on narrow terminals (full → reduced → minimal)
- Use printf '\033[J' to clear stale content when list height shrinks
- Guard empty-array expansions with ${arr[*]-} for set -u safety
- Add BATS tests for confirm_purge_cleanup (Enter confirm, ESC cancel)
This commit is contained in:
tw93
2026-02-26 19:42:42 +08:00
parent 837df390a5
commit d13c0927a6
2 changed files with 214 additions and 28 deletions

View File

@@ -633,7 +633,6 @@ select_purge_categories() {
scroll_indicator=" ${GRAY}[${current_pos}/${total_items}]${NC}"
fi
printf "%s\n" "$clear_line"
printf "%s${PURPLE_BOLD}Select Categories to Clean${NC}%s${GRAY}, ${selected_size_human}, ${selected_count} selected${NC}\n" "$clear_line" "$scroll_indicator"
printf "%s\n" "$clear_line"
@@ -656,15 +655,42 @@ select_purge_categories() {
fi
done
# Fill empty slots to clear previous content
local items_shown=$visible_count
for ((i = items_shown; i < items_per_page; i++)); do
printf "%s\n" "$clear_line"
done
# Keep one blank line between the list and footer tips.
printf "%s\n" "$clear_line"
printf "%s${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN}/J/K | Space Select | Enter Confirm | A All | I Invert | Q Quit${NC}\n" "$clear_line"
# Adaptive footer hints — mirrors menu_paginated.sh pattern
local _term_w
_term_w=$(tput cols 2> /dev/null || echo 80)
[[ "$_term_w" =~ ^[0-9]+$ ]] || _term_w=80
local _sep=" ${GRAY}|${NC} "
local _nav="${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN}${NC}"
local _space="${GRAY}Space Select${NC}"
local _enter="${GRAY}Enter Confirm${NC}"
local _all="${GRAY}A All${NC}"
local _invert="${GRAY}I Invert${NC}"
local _quit="${GRAY}Q Quit${NC}"
# Strip ANSI to measure real length
_ph_len() { printf "%s" "$1" | LC_ALL=C awk '{gsub(/\033\[[0-9;]*[A-Za-z]/,""); printf "%d", length}'; }
# Level 0 (full): ↑↓ | Space Select | Enter Confirm | A All | I Invert | Q Quit
local _full="${_nav}${_sep}${_space}${_sep}${_enter}${_sep}${_all}${_sep}${_invert}${_sep}${_quit}"
if (($(_ph_len "$_full") <= _term_w)); then
printf "%s${_full}${NC}\n" "$clear_line"
else
# Level 1: ↑↓ | Enter Confirm | A All | I Invert | Q Quit
local _l1="${_nav}${_sep}${_enter}${_sep}${_all}${_sep}${_invert}${_sep}${_quit}"
if (($(_ph_len "$_l1") <= _term_w)); then
printf "%s${_l1}${NC}\n" "$clear_line"
else
# Level 2 (minimal): ↑↓ | Enter | Q Quit
printf "%s${_nav}${_sep}${_enter}${_sep}${_quit}${NC}\n" "$clear_line"
fi
fi
# Clear stale content below the footer when list height shrinks.
printf '\033[J'
}
move_cursor_up() {
if [[ $cursor_pos -gt 0 ]]; then
@@ -767,6 +793,48 @@ select_purge_categories() {
esac
done
}
# Final confirmation before deleting selected purge artifacts.
confirm_purge_cleanup() {
local item_count="${1:-0}"
local total_size_kb="${2:-0}"
local unknown_count="${3:-0}"
[[ "$item_count" =~ ^[0-9]+$ ]] || item_count=0
[[ "$total_size_kb" =~ ^[0-9]+$ ]] || total_size_kb=0
[[ "$unknown_count" =~ ^[0-9]+$ ]] || unknown_count=0
local item_text="artifact"
[[ $item_count -ne 1 ]] && item_text="artifacts"
local size_display
size_display=$(bytes_to_human "$((total_size_kb * 1024))")
local unknown_hint=""
if [[ $unknown_count -gt 0 ]]; then
local unknown_text="unknown size"
[[ $unknown_count -gt 1 ]] && unknown_text="unknown sizes"
unknown_hint=", ${unknown_count} ${unknown_text}"
fi
echo -ne "${PURPLE}${ICON_ARROW}${NC} Remove ${item_count} ${item_text}, ${size_display}${unknown_hint} ${GREEN}Enter${NC} confirm, ${GRAY}ESC${NC} cancel: "
drain_pending_input
local key=""
IFS= read -r -s -n1 key || key=""
drain_pending_input
case "$key" in
"" | $'\n' | $'\r' | y | Y)
echo ""
return 0
;;
*)
echo ""
return 1
;;
esac
}
# Main cleanup function - scans and prompts user to select artifacts to clean
clean_project_artifacts() {
local -a all_found_items=()
@@ -825,8 +893,6 @@ clean_project_artifacts() {
# Give monitor process time to exit and clear its output
if [[ -t 1 ]]; then
sleep 0.2
# Clear the scanning line but preserve the title
printf '\n\033[K'
fi
# Collect all results
@@ -1041,32 +1107,57 @@ clean_project_artifacts() {
echo "$artifact_name"
fi
}
# Format display with alignment (like app_selector)
# Format display with alignment (mirrors app_selector.sh approach)
# Args: $1=project_path $2=artifact_type $3=size_str $4=terminal_width $5=max_path_width $6=artifact_col_width
format_purge_display() {
local project_path="$1"
local artifact_type="$2"
local size_str="$3"
# Terminal width for alignment
local terminal_width=$(tput cols 2> /dev/null || echo 80)
local fixed_width=32 # Reserve for size and artifact type (9 + 3 + 20)
local available_width=$((terminal_width - fixed_width))
# Bounds: 30 chars min, but cap at 70% of terminal width to preserve aesthetics
local max_aesthetic_width=$((terminal_width * 70 / 100))
[[ $available_width -gt $max_aesthetic_width ]] && available_width=$max_aesthetic_width
[[ $available_width -lt 30 ]] && available_width=30
local terminal_width="${4:-$(tput cols 2> /dev/null || echo 80)}"
local max_path_width="${5:-}"
local artifact_col="${6:-12}"
local available_width
if [[ -n "$max_path_width" ]]; then
available_width="$max_path_width"
else
# Standalone fallback: overhead = prefix(4)+space(1)+size(9)+sep(3)+artifact_col+recent(9) = artifact_col+26
local fixed_width=$((artifact_col + 26))
available_width=$((terminal_width - fixed_width))
local min_width=10
if [[ $terminal_width -ge 120 ]]; then
min_width=48
elif [[ $terminal_width -ge 100 ]]; then
min_width=38
elif [[ $terminal_width -ge 80 ]]; then
min_width=25
fi
[[ $available_width -lt $min_width ]] && available_width=$min_width
[[ $available_width -gt 60 ]] && available_width=60
fi
# Truncate project path if needed
local truncated_path=$(truncate_by_display_width "$project_path" "$available_width")
local current_width=$(get_display_width "$truncated_path")
local truncated_path
truncated_path=$(truncate_by_display_width "$project_path" "$available_width")
local current_width
current_width=$(get_display_width "$truncated_path")
local char_count=${#truncated_path}
local padding=$((available_width - current_width))
local printf_width=$((char_count + padding))
# Format: "project_path size | artifact_type"
printf "%-*s %9s | %-17s" "$printf_width" "$truncated_path" "$size_str" "$artifact_type"
printf "%-*s %9s | %-*s" "$printf_width" "$truncated_path" "$size_str" "$artifact_col" "$artifact_type"
}
# Build menu options - one line per artifact
# Pass 1: collect data into parallel arrays (needed for pre-scan of widths)
local -a raw_project_paths=()
local -a raw_artifact_types=()
for item in "${safe_to_clean[@]}"; do
local project_path=$(get_project_path "$item")
local artifact_type=$(get_artifact_display_name "$item")
local project_path
project_path=$(get_project_path "$item")
local artifact_type
artifact_type=$(get_artifact_display_name "$item")
local size_raw
size_raw=$(get_dir_size_kb "$item")
local size_kb=0
@@ -1095,13 +1186,66 @@ clean_project_artifacts() {
break
fi
done
menu_options+=("$(format_purge_display "$project_path" "$artifact_type" "$size_human")")
raw_project_paths+=("$project_path")
raw_artifact_types+=("$artifact_type")
item_paths+=("$item")
item_sizes+=("$size_kb")
item_size_unknown_flags+=("$size_unknown")
item_recent_flags+=("$is_recent")
done
# Pre-scan: find max path and artifact display widths (mirrors app_selector.sh approach)
local terminal_width
terminal_width=$(tput cols 2> /dev/null || echo 80)
[[ "$terminal_width" =~ ^[0-9]+$ ]] || terminal_width=80
local max_path_display_width=0
local max_artifact_width=0
for pp in "${raw_project_paths[@]+"${raw_project_paths[@]}"}"; do
local w
w=$(get_display_width "$pp")
[[ $w -gt $max_path_display_width ]] && max_path_display_width=$w
done
for at in "${raw_artifact_types[@]+"${raw_artifact_types[@]}"}"; do
[[ ${#at} -gt $max_artifact_width ]] && max_artifact_width=${#at}
done
# Artifact column: cap at 17, floor at 6 (shortest typical names like "dist")
[[ $max_artifact_width -lt 6 ]] && max_artifact_width=6
[[ $max_artifact_width -gt 17 ]] && max_artifact_width=17
# Exact overhead: prefix(4) + space(1) + size(9) + " | "(3) + artifact_col + " | Recent"(9) = artifact_col + 26
local fixed_overhead=$((max_artifact_width + 26))
local available_for_path=$((terminal_width - fixed_overhead))
local min_path_width=10
if [[ $terminal_width -ge 120 ]]; then
min_path_width=48
elif [[ $terminal_width -ge 100 ]]; then
min_path_width=38
elif [[ $terminal_width -ge 80 ]]; then
min_path_width=25
fi
[[ $max_path_display_width -lt $min_path_width ]] && max_path_display_width=$min_path_width
[[ $available_for_path -lt $max_path_display_width ]] && max_path_display_width=$available_for_path
[[ $max_path_display_width -gt 60 ]] && max_path_display_width=60
# Ensure path width is at least 5 on very narrow terminals
[[ $max_path_display_width -lt 5 ]] && max_path_display_width=5
# Pass 2: build menu_options using pre-computed widths
for ((idx = 0; idx < ${#raw_project_paths[@]}; idx++)); do
local size_kb_val="${item_sizes[idx]}"
local size_unknown_val="${item_size_unknown_flags[idx]}"
local size_human_val=""
if [[ "$size_unknown_val" == "true" ]]; then
size_human_val="unknown"
else
size_human_val=$(bytes_to_human "$((size_kb_val * 1024))")
fi
menu_options+=("$(format_purge_display "${raw_project_paths[idx]}" "${raw_artifact_types[idx]}" "$size_human_val" "$terminal_width" "$max_path_display_width" "$max_artifact_width")")
done
# Sort by size descending (largest first) - requested in issue #311
# Use external sort for better performance with many items
if [[ ${#item_sizes[@]} -gt 0 ]]; then
@@ -1147,11 +1291,11 @@ clean_project_artifacts() {
# Set global vars for selector
export PURGE_CATEGORY_SIZES=$(
IFS=,
echo "${item_sizes[*]}"
echo "${item_sizes[*]-}"
)
export PURGE_RECENT_CATEGORIES=$(
IFS=,
echo "${item_recent_flags[*]}"
echo "${item_recent_flags[*]-}"
)
# Interactive selection (only if terminal is available)
PURGE_SELECTION_RESULT=""
@@ -1176,9 +1320,29 @@ clean_project_artifacts() {
unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT
return 0
fi
IFS=',' read -r -a selected_indices <<< "$PURGE_SELECTION_RESULT"
local selected_total_kb=0
local selected_unknown_count=0
for idx in "${selected_indices[@]}"; do
local selected_size_kb="${item_sizes[idx]:-0}"
[[ "$selected_size_kb" =~ ^[0-9]+$ ]] || selected_size_kb=0
selected_total_kb=$((selected_total_kb + selected_size_kb))
if [[ "${item_size_unknown_flags[idx]:-false}" == "true" ]]; then
((selected_unknown_count++))
fi
done
if [[ -t 0 ]]; then
if ! confirm_purge_cleanup "${#selected_indices[@]}" "$selected_total_kb" "$selected_unknown_count"; then
echo -e "${GRAY}Purge cancelled${NC}"
printf '\n'
unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT
return 1
fi
fi
# Clean selected items
echo ""
IFS=',' read -r -a selected_indices <<< "$PURGE_SELECTION_RESULT"
local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole"
local cleaned_count=0
for idx in "${selected_indices[@]}"; do

View File

@@ -308,6 +308,28 @@ EOF
[ "$status" -eq 0 ]
}
@test "confirm_purge_cleanup accepts Enter" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/clean/project.sh"
drain_pending_input() { :; }
confirm_purge_cleanup 2 1024 0 <<< ''
EOF
[ "$status" -eq 0 ]
}
@test "confirm_purge_cleanup cancels on ESC" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/clean/project.sh"
drain_pending_input() { :; }
confirm_purge_cleanup 2 1024 0 <<< $'\033'
EOF
[ "$status" -eq 1 ]
}
@test "is_protected_vendor_dir: protects Go vendor" {
mkdir -p "$HOME/www/go-app/vendor"
touch "$HOME/www/go-app/go.mod"