mac_cleaner 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +53 -0
- data/exe/mac_cleaner +5 -0
- data/lib/common.sh +1751 -0
- data/lib/mac_cleaner/analyzer.rb +156 -0
- data/lib/mac_cleaner/cleaner.rb +540 -0
- data/lib/mac_cleaner/cli.rb +27 -0
- data/lib/mac_cleaner/version.rb +3 -0
- data/lib/mac_cleaner.rb +8 -0
- data/lib/paginated_menu.sh +688 -0
- data/lib/simple_menu.sh +292 -0
- data/lib/whitelist_manager.sh +289 -0
- metadata +72 -5
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Paginated menu with arrow key navigation
|
|
3
|
+
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
# Terminal control functions
|
|
7
|
+
enter_alt_screen() {
|
|
8
|
+
if command -v tput > /dev/null 2>&1 && [[ -t 1 ]]; then
|
|
9
|
+
tput smcup 2> /dev/null || true
|
|
10
|
+
fi
|
|
11
|
+
}
|
|
12
|
+
leave_alt_screen() {
|
|
13
|
+
if command -v tput > /dev/null 2>&1 && [[ -t 1 ]]; then
|
|
14
|
+
tput rmcup 2> /dev/null || true
|
|
15
|
+
fi
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
# Parse CSV into newline list (Bash 3.2)
|
|
19
|
+
_pm_parse_csv_to_array() {
|
|
20
|
+
local csv="${1:-}"
|
|
21
|
+
if [[ -z "$csv" ]]; then
|
|
22
|
+
return 0
|
|
23
|
+
fi
|
|
24
|
+
local IFS=','
|
|
25
|
+
for _tok in $csv; do
|
|
26
|
+
printf "%s\n" "$_tok"
|
|
27
|
+
done
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# Non-blocking input drain (bash 3.2)
|
|
31
|
+
drain_pending_input() {
|
|
32
|
+
local _k
|
|
33
|
+
# -t 0 is non-blocking; -n 1 consumes one byte at a time
|
|
34
|
+
while IFS= read -r -s -n 1 -t 0 _k; do
|
|
35
|
+
IFS= read -r -s -n 1 _k || break
|
|
36
|
+
done
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Main paginated multi-select menu function
|
|
40
|
+
paginated_multi_select() {
|
|
41
|
+
local title="$1"
|
|
42
|
+
shift
|
|
43
|
+
local -a items=("$@")
|
|
44
|
+
local external_alt_screen=false
|
|
45
|
+
if [[ "${MOLE_MANAGED_ALT_SCREEN:-}" == "1" || "${MOLE_MANAGED_ALT_SCREEN:-}" == "true" ]]; then
|
|
46
|
+
external_alt_screen=true
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
# Validation
|
|
50
|
+
if [[ ${#items[@]} -eq 0 ]]; then
|
|
51
|
+
echo "No items provided" >&2
|
|
52
|
+
return 1
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
local total_items=${#items[@]}
|
|
56
|
+
local items_per_page=15
|
|
57
|
+
local cursor_pos=0
|
|
58
|
+
local top_index=0
|
|
59
|
+
local filter_query=""
|
|
60
|
+
local filter_mode="false" # filter mode toggle
|
|
61
|
+
local sort_mode="${MOLE_MENU_SORT_DEFAULT:-date}" # date|name|size
|
|
62
|
+
local sort_reverse="false"
|
|
63
|
+
# Live query vs applied query
|
|
64
|
+
local applied_query=""
|
|
65
|
+
local searching="false"
|
|
66
|
+
|
|
67
|
+
# Metadata (optional)
|
|
68
|
+
# epochs[i] -> last_used_epoch (numeric) for item i
|
|
69
|
+
# sizekb[i] -> size in KB (numeric) for item i
|
|
70
|
+
local -a epochs=()
|
|
71
|
+
local -a sizekb=()
|
|
72
|
+
local has_metadata="false"
|
|
73
|
+
if [[ -n "${MOLE_MENU_META_EPOCHS:-}" ]]; then
|
|
74
|
+
while IFS= read -r v; do epochs+=("${v:-0}"); done < <(_pm_parse_csv_to_array "$MOLE_MENU_META_EPOCHS")
|
|
75
|
+
has_metadata="true"
|
|
76
|
+
fi
|
|
77
|
+
if [[ -n "${MOLE_MENU_META_SIZEKB:-}" ]]; then
|
|
78
|
+
while IFS= read -r v; do sizekb+=("${v:-0}"); done < <(_pm_parse_csv_to_array "$MOLE_MENU_META_SIZEKB")
|
|
79
|
+
has_metadata="true"
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
# If no metadata, force name sorting and disable sorting controls
|
|
83
|
+
if [[ "$has_metadata" == "false" && "$sort_mode" != "name" ]]; then
|
|
84
|
+
sort_mode="name"
|
|
85
|
+
fi
|
|
86
|
+
|
|
87
|
+
# Index mappings
|
|
88
|
+
local -a orig_indices=()
|
|
89
|
+
local -a view_indices=()
|
|
90
|
+
local i
|
|
91
|
+
for ((i = 0; i < total_items; i++)); do
|
|
92
|
+
orig_indices[i]=$i
|
|
93
|
+
view_indices[i]=$i
|
|
94
|
+
done
|
|
95
|
+
|
|
96
|
+
# Escape for shell globbing without upsetting highlighters
|
|
97
|
+
_pm_escape_glob() {
|
|
98
|
+
local s="${1-}" out="" c
|
|
99
|
+
local i len=${#s}
|
|
100
|
+
for ((i = 0; i < len; i++)); do
|
|
101
|
+
c="${s:i:1}"
|
|
102
|
+
case "$c" in
|
|
103
|
+
'\' | '*' | '?' | '[' | ']') out+="\\$c" ;;
|
|
104
|
+
*) out+="$c" ;;
|
|
105
|
+
esac
|
|
106
|
+
done
|
|
107
|
+
printf '%s' "$out"
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# Case-insensitive fuzzy match (substring search)
|
|
111
|
+
_pm_match() {
|
|
112
|
+
local hay="$1" q="$2"
|
|
113
|
+
q="$(_pm_escape_glob "$q")"
|
|
114
|
+
local pat="*${q}*"
|
|
115
|
+
|
|
116
|
+
shopt -s nocasematch
|
|
117
|
+
local ok=1
|
|
118
|
+
# shellcheck disable=SC2254 # intentional glob match with a computed pattern
|
|
119
|
+
case "$hay" in
|
|
120
|
+
$pat) ok=0 ;;
|
|
121
|
+
esac
|
|
122
|
+
shopt -u nocasematch
|
|
123
|
+
return $ok
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
local -a selected=()
|
|
127
|
+
|
|
128
|
+
# Initialize selection array
|
|
129
|
+
for ((i = 0; i < total_items; i++)); do
|
|
130
|
+
selected[i]=false
|
|
131
|
+
done
|
|
132
|
+
|
|
133
|
+
if [[ -n "${MOLE_PRESELECTED_INDICES:-}" ]]; then
|
|
134
|
+
local cleaned_preselect="${MOLE_PRESELECTED_INDICES//[[:space:]]/}"
|
|
135
|
+
local -a initial_indices=()
|
|
136
|
+
IFS=',' read -ra initial_indices <<< "$cleaned_preselect"
|
|
137
|
+
for idx in "${initial_indices[@]}"; do
|
|
138
|
+
if [[ "$idx" =~ ^[0-9]+$ && $idx -ge 0 && $idx -lt $total_items ]]; then
|
|
139
|
+
selected[idx]=true
|
|
140
|
+
fi
|
|
141
|
+
done
|
|
142
|
+
fi
|
|
143
|
+
|
|
144
|
+
# Preserve original TTY settings so we can restore them reliably
|
|
145
|
+
local original_stty=""
|
|
146
|
+
if [[ -t 0 ]] && command -v stty > /dev/null 2>&1; then
|
|
147
|
+
original_stty=$(stty -g 2> /dev/null || echo "")
|
|
148
|
+
fi
|
|
149
|
+
|
|
150
|
+
restore_terminal() {
|
|
151
|
+
show_cursor
|
|
152
|
+
if [[ -n "${original_stty-}" ]]; then
|
|
153
|
+
stty "${original_stty}" 2> /dev/null || stty sane 2> /dev/null || stty echo icanon 2> /dev/null || true
|
|
154
|
+
else
|
|
155
|
+
stty sane 2> /dev/null || stty echo icanon 2> /dev/null || true
|
|
156
|
+
fi
|
|
157
|
+
if [[ "${external_alt_screen:-false}" == false ]]; then
|
|
158
|
+
leave_alt_screen
|
|
159
|
+
fi
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# Cleanup function
|
|
163
|
+
cleanup() {
|
|
164
|
+
trap - EXIT INT TERM
|
|
165
|
+
restore_terminal
|
|
166
|
+
unset MOLE_READ_KEY_FORCE_CHAR
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
# Interrupt handler
|
|
170
|
+
handle_interrupt() {
|
|
171
|
+
cleanup
|
|
172
|
+
exit 130 # Standard exit code for Ctrl+C
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
trap cleanup EXIT
|
|
176
|
+
trap handle_interrupt INT TERM
|
|
177
|
+
|
|
178
|
+
# Setup terminal - preserve interrupt character
|
|
179
|
+
stty -echo -icanon intr ^C 2> /dev/null || true
|
|
180
|
+
if [[ $external_alt_screen == false ]]; then
|
|
181
|
+
enter_alt_screen
|
|
182
|
+
# Clear screen once on entry to alt screen
|
|
183
|
+
printf "\033[2J\033[H" >&2
|
|
184
|
+
else
|
|
185
|
+
printf "\033[H" >&2
|
|
186
|
+
fi
|
|
187
|
+
hide_cursor
|
|
188
|
+
|
|
189
|
+
# Helper functions
|
|
190
|
+
print_line() { printf "\r\033[2K%s\n" "$1" >&2; }
|
|
191
|
+
|
|
192
|
+
# Print footer lines wrapping only at separators
|
|
193
|
+
_print_wrapped_controls() {
|
|
194
|
+
local sep="$1"
|
|
195
|
+
shift
|
|
196
|
+
local -a segs=("$@")
|
|
197
|
+
|
|
198
|
+
local cols="${COLUMNS:-}"
|
|
199
|
+
[[ -z "$cols" ]] && cols=$(tput cols 2> /dev/null || echo 80)
|
|
200
|
+
|
|
201
|
+
_strip_ansi_len() {
|
|
202
|
+
local text="$1"
|
|
203
|
+
local stripped
|
|
204
|
+
stripped=$(printf "%s" "$text" | LC_ALL=C awk '{gsub(/\033\[[0-9;]*[A-Za-z]/,""); print}')
|
|
205
|
+
printf "%d" "${#stripped}"
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
local line="" s candidate
|
|
209
|
+
local clear_line=$'\r\033[2K'
|
|
210
|
+
for s in "${segs[@]}"; do
|
|
211
|
+
if [[ -z "$line" ]]; then
|
|
212
|
+
candidate="$s"
|
|
213
|
+
else
|
|
214
|
+
candidate="$line${sep}${s}"
|
|
215
|
+
fi
|
|
216
|
+
if (($(_strip_ansi_len "$candidate") > cols)); then
|
|
217
|
+
printf "%s%s\n" "$clear_line" "$line" >&2
|
|
218
|
+
line="$s"
|
|
219
|
+
else
|
|
220
|
+
line="$candidate"
|
|
221
|
+
fi
|
|
222
|
+
done
|
|
223
|
+
printf "%s%s\n" "$clear_line" "$line" >&2
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
# Rebuild the view_indices applying filter and sort
|
|
227
|
+
rebuild_view() {
|
|
228
|
+
# Filter
|
|
229
|
+
local -a filtered=()
|
|
230
|
+
local effective_query=""
|
|
231
|
+
if [[ "$filter_mode" == "true" ]]; then
|
|
232
|
+
# Live editing: empty query -> show all items
|
|
233
|
+
effective_query="$filter_query"
|
|
234
|
+
if [[ -z "$effective_query" ]]; then
|
|
235
|
+
filtered=("${orig_indices[@]}")
|
|
236
|
+
else
|
|
237
|
+
local idx
|
|
238
|
+
for ((idx = 0; idx < total_items; idx++)); do
|
|
239
|
+
if _pm_match "${items[idx]}" "$effective_query"; then
|
|
240
|
+
filtered+=("$idx")
|
|
241
|
+
fi
|
|
242
|
+
done
|
|
243
|
+
fi
|
|
244
|
+
else
|
|
245
|
+
# Normal mode: use applied query; empty -> show all
|
|
246
|
+
effective_query="$applied_query"
|
|
247
|
+
if [[ -z "$effective_query" ]]; then
|
|
248
|
+
filtered=("${orig_indices[@]}")
|
|
249
|
+
else
|
|
250
|
+
local idx
|
|
251
|
+
for ((idx = 0; idx < total_items; idx++)); do
|
|
252
|
+
if _pm_match "${items[idx]}" "$effective_query"; then
|
|
253
|
+
filtered+=("$idx")
|
|
254
|
+
fi
|
|
255
|
+
done
|
|
256
|
+
fi
|
|
257
|
+
fi
|
|
258
|
+
|
|
259
|
+
# Sort (skip if no metadata)
|
|
260
|
+
if [[ "$has_metadata" == "false" ]]; then
|
|
261
|
+
# No metadata: just use filtered list (already sorted by name naturally)
|
|
262
|
+
view_indices=("${filtered[@]}")
|
|
263
|
+
elif [[ ${#filtered[@]} -eq 0 ]]; then
|
|
264
|
+
view_indices=()
|
|
265
|
+
else
|
|
266
|
+
# Build sort key
|
|
267
|
+
local sort_key
|
|
268
|
+
if [[ "$sort_mode" == "date" ]]; then
|
|
269
|
+
# Date: ascending by default (oldest first)
|
|
270
|
+
sort_key="-k1,1n"
|
|
271
|
+
[[ "$sort_reverse" == "true" ]] && sort_key="-k1,1nr"
|
|
272
|
+
elif [[ "$sort_mode" == "size" ]]; then
|
|
273
|
+
# Size: descending by default (largest first)
|
|
274
|
+
sort_key="-k1,1nr"
|
|
275
|
+
[[ "$sort_reverse" == "true" ]] && sort_key="-k1,1n"
|
|
276
|
+
else
|
|
277
|
+
# Name: ascending by default (A to Z)
|
|
278
|
+
sort_key="-k1,1f"
|
|
279
|
+
[[ "$sort_reverse" == "true" ]] && sort_key="-k1,1fr"
|
|
280
|
+
fi
|
|
281
|
+
|
|
282
|
+
# Create temporary file for sorting
|
|
283
|
+
local tmpfile
|
|
284
|
+
tmpfile=$(mktemp 2> /dev/null) || tmpfile=""
|
|
285
|
+
if [[ -n "$tmpfile" ]]; then
|
|
286
|
+
local k id
|
|
287
|
+
for id in "${filtered[@]}"; do
|
|
288
|
+
case "$sort_mode" in
|
|
289
|
+
date) k="${epochs[id]:-0}" ;;
|
|
290
|
+
size) k="${sizekb[id]:-0}" ;;
|
|
291
|
+
name | *) k="${items[id]}|${id}" ;;
|
|
292
|
+
esac
|
|
293
|
+
printf "%s\t%s\n" "$k" "$id" >> "$tmpfile"
|
|
294
|
+
done
|
|
295
|
+
|
|
296
|
+
view_indices=()
|
|
297
|
+
while IFS=$'\t' read -r _key _id; do
|
|
298
|
+
[[ -z "$_id" ]] && continue
|
|
299
|
+
view_indices+=("$_id")
|
|
300
|
+
done < <(LC_ALL=C sort -t $'\t' $sort_key -- "$tmpfile" 2> /dev/null)
|
|
301
|
+
|
|
302
|
+
rm -f "$tmpfile"
|
|
303
|
+
else
|
|
304
|
+
# Fallback: no sorting
|
|
305
|
+
view_indices=("${filtered[@]}")
|
|
306
|
+
fi
|
|
307
|
+
fi
|
|
308
|
+
|
|
309
|
+
# Clamp cursor into visible range
|
|
310
|
+
local visible_count=${#view_indices[@]}
|
|
311
|
+
local max_top
|
|
312
|
+
if [[ $visible_count -gt $items_per_page ]]; then
|
|
313
|
+
max_top=$((visible_count - items_per_page))
|
|
314
|
+
else
|
|
315
|
+
max_top=0
|
|
316
|
+
fi
|
|
317
|
+
[[ $top_index -gt $max_top ]] && top_index=$max_top
|
|
318
|
+
local current_visible=$((visible_count - top_index))
|
|
319
|
+
[[ $current_visible -gt $items_per_page ]] && current_visible=$items_per_page
|
|
320
|
+
if [[ $cursor_pos -ge $current_visible ]]; then
|
|
321
|
+
cursor_pos=$((current_visible > 0 ? current_visible - 1 : 0))
|
|
322
|
+
fi
|
|
323
|
+
[[ $cursor_pos -lt 0 ]] && cursor_pos=0
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
# Initial view (default sort)
|
|
327
|
+
rebuild_view
|
|
328
|
+
|
|
329
|
+
render_item() {
|
|
330
|
+
# $1: visible row index (0..items_per_page-1 in current window)
|
|
331
|
+
# $2: is_current flag
|
|
332
|
+
local vrow=$1 is_current=$2
|
|
333
|
+
local idx=$((top_index + vrow))
|
|
334
|
+
local real="${view_indices[idx]:--1}"
|
|
335
|
+
[[ $real -lt 0 ]] && return
|
|
336
|
+
local checkbox="$ICON_EMPTY"
|
|
337
|
+
[[ ${selected[real]} == true ]] && checkbox="$ICON_SOLID"
|
|
338
|
+
|
|
339
|
+
if [[ $is_current == true ]]; then
|
|
340
|
+
printf "\r\033[2K${BLUE}${ICON_ARROW} %s %s${NC}\n" "$checkbox" "${items[real]}" >&2
|
|
341
|
+
else
|
|
342
|
+
printf "\r\033[2K %s %s\n" "$checkbox" "${items[real]}" >&2
|
|
343
|
+
fi
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
# Draw the complete menu
|
|
347
|
+
draw_menu() {
|
|
348
|
+
printf "\033[H" >&2
|
|
349
|
+
local clear_line="\r\033[2K"
|
|
350
|
+
|
|
351
|
+
# Count selections
|
|
352
|
+
local selected_count=0
|
|
353
|
+
for ((i = 0; i < total_items; i++)); do
|
|
354
|
+
[[ ${selected[i]} == true ]] && ((selected_count++))
|
|
355
|
+
done
|
|
356
|
+
|
|
357
|
+
# Header only
|
|
358
|
+
printf "${clear_line}${PURPLE}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2
|
|
359
|
+
|
|
360
|
+
# Visible slice
|
|
361
|
+
local visible_total=${#view_indices[@]}
|
|
362
|
+
if [[ $visible_total -eq 0 ]]; then
|
|
363
|
+
if [[ "$filter_mode" == "true" ]]; then
|
|
364
|
+
# While editing: do not show "No items available"
|
|
365
|
+
for ((i = 0; i < items_per_page + 2; i++)); do
|
|
366
|
+
printf "${clear_line}\n" >&2
|
|
367
|
+
done
|
|
368
|
+
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
|
|
369
|
+
printf "${clear_line}" >&2
|
|
370
|
+
return
|
|
371
|
+
else
|
|
372
|
+
if [[ "$searching" == "true" ]]; then
|
|
373
|
+
printf "${clear_line}${GRAY}Searching…${NC}\n" >&2
|
|
374
|
+
for ((i = 0; i < items_per_page + 2; i++)); do
|
|
375
|
+
printf "${clear_line}\n" >&2
|
|
376
|
+
done
|
|
377
|
+
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
|
|
378
|
+
printf "${clear_line}" >&2
|
|
379
|
+
return
|
|
380
|
+
else
|
|
381
|
+
# Post-search: truly empty list
|
|
382
|
+
printf "${clear_line}${GRAY}No items available${NC}\n" >&2
|
|
383
|
+
for ((i = 0; i < items_per_page + 2; i++)); do
|
|
384
|
+
printf "${clear_line}\n" >&2
|
|
385
|
+
done
|
|
386
|
+
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
|
|
387
|
+
printf "${clear_line}" >&2
|
|
388
|
+
return
|
|
389
|
+
fi
|
|
390
|
+
fi
|
|
391
|
+
fi
|
|
392
|
+
|
|
393
|
+
local visible_count=$((visible_total - top_index))
|
|
394
|
+
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
|
|
395
|
+
[[ $visible_count -le 0 ]] && visible_count=1
|
|
396
|
+
if [[ $cursor_pos -ge $visible_count ]]; then
|
|
397
|
+
cursor_pos=$((visible_count - 1))
|
|
398
|
+
[[ $cursor_pos -lt 0 ]] && cursor_pos=0
|
|
399
|
+
fi
|
|
400
|
+
|
|
401
|
+
printf "${clear_line}\n" >&2
|
|
402
|
+
|
|
403
|
+
# Items for current window
|
|
404
|
+
local start_idx=$top_index
|
|
405
|
+
local end_idx=$((top_index + items_per_page - 1))
|
|
406
|
+
[[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1))
|
|
407
|
+
|
|
408
|
+
for ((i = start_idx; i <= end_idx; i++)); do
|
|
409
|
+
[[ $i -lt 0 ]] && continue
|
|
410
|
+
local is_current=false
|
|
411
|
+
[[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true
|
|
412
|
+
render_item $((i - start_idx)) $is_current
|
|
413
|
+
done
|
|
414
|
+
|
|
415
|
+
# Fill empty slots to clear previous content
|
|
416
|
+
local items_shown=$((end_idx - start_idx + 1))
|
|
417
|
+
[[ $items_shown -lt 0 ]] && items_shown=0
|
|
418
|
+
for ((i = items_shown; i < items_per_page; i++)); do
|
|
419
|
+
printf "${clear_line}\n" >&2
|
|
420
|
+
done
|
|
421
|
+
|
|
422
|
+
printf "${clear_line}\n" >&2
|
|
423
|
+
|
|
424
|
+
# Build sort and filter status
|
|
425
|
+
local sort_label=""
|
|
426
|
+
case "$sort_mode" in
|
|
427
|
+
date) sort_label="Date" ;;
|
|
428
|
+
name) sort_label="Name" ;;
|
|
429
|
+
size) sort_label="Size" ;;
|
|
430
|
+
esac
|
|
431
|
+
local arrow="↑"
|
|
432
|
+
[[ "$sort_reverse" == "true" ]] && arrow="↓"
|
|
433
|
+
local sort_status="${sort_label} ${arrow}"
|
|
434
|
+
|
|
435
|
+
local filter_status=""
|
|
436
|
+
if [[ "$filter_mode" == "true" ]]; then
|
|
437
|
+
filter_status="${YELLOW}${filter_query:-}${NC}"
|
|
438
|
+
elif [[ -n "$applied_query" ]]; then
|
|
439
|
+
filter_status="${GREEN}${applied_query}${NC}"
|
|
440
|
+
else
|
|
441
|
+
filter_status="${GRAY}—${NC}"
|
|
442
|
+
fi
|
|
443
|
+
|
|
444
|
+
# Footer with two lines: basic controls and advanced options
|
|
445
|
+
local sep=" ${GRAY}|${NC} "
|
|
446
|
+
if [[ "$filter_mode" == "true" ]]; then
|
|
447
|
+
# Filter mode: single line with all filter controls
|
|
448
|
+
local -a _segs_filter=(
|
|
449
|
+
"${GRAY}Filter Input:${NC} ${filter_status}"
|
|
450
|
+
"${GRAY}Delete${NC} Back"
|
|
451
|
+
"${GRAY}Enter${NC} Apply"
|
|
452
|
+
"${GRAY}/${NC} Clear"
|
|
453
|
+
"${GRAY}ESC${NC} Cancel"
|
|
454
|
+
)
|
|
455
|
+
_print_wrapped_controls "$sep" "${_segs_filter[@]}"
|
|
456
|
+
else
|
|
457
|
+
# Normal mode
|
|
458
|
+
if [[ "$has_metadata" == "true" ]]; then
|
|
459
|
+
# With metadata: two lines (basic + advanced)
|
|
460
|
+
local -a _segs_basic=(
|
|
461
|
+
"${GRAY}${ICON_NAV_UP}/${ICON_NAV_DOWN}${NC} Navigate"
|
|
462
|
+
"${GRAY}Space${NC} Select"
|
|
463
|
+
"${GRAY}Enter${NC} Confirm"
|
|
464
|
+
"${GRAY}Q/ESC${NC} Quit"
|
|
465
|
+
)
|
|
466
|
+
_print_wrapped_controls "$sep" "${_segs_basic[@]}"
|
|
467
|
+
local -a _segs_advanced=(
|
|
468
|
+
"${GRAY}S${NC} ${sort_status}"
|
|
469
|
+
"${GRAY}R${NC} Reverse"
|
|
470
|
+
"${GRAY}/${NC} Filter"
|
|
471
|
+
)
|
|
472
|
+
_print_wrapped_controls "$sep" "${_segs_advanced[@]}"
|
|
473
|
+
else
|
|
474
|
+
# Without metadata: single line (basic only)
|
|
475
|
+
local -a _segs_simple=(
|
|
476
|
+
"${GRAY}${ICON_NAV_UP}/${ICON_NAV_DOWN}${NC} Navigate"
|
|
477
|
+
"${GRAY}Space${NC} Select"
|
|
478
|
+
"${GRAY}Enter${NC} Confirm"
|
|
479
|
+
"${GRAY}/${NC} Filter"
|
|
480
|
+
"${GRAY}Q/ESC${NC} Quit"
|
|
481
|
+
)
|
|
482
|
+
_print_wrapped_controls "$sep" "${_segs_simple[@]}"
|
|
483
|
+
fi
|
|
484
|
+
fi
|
|
485
|
+
printf "${clear_line}" >&2
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
# Main interaction loop
|
|
489
|
+
while true; do
|
|
490
|
+
draw_menu
|
|
491
|
+
local key
|
|
492
|
+
key=$(read_key)
|
|
493
|
+
|
|
494
|
+
case "$key" in
|
|
495
|
+
"QUIT")
|
|
496
|
+
if [[ "$filter_mode" == "true" ]]; then
|
|
497
|
+
filter_mode="false"
|
|
498
|
+
unset MOLE_READ_KEY_FORCE_CHAR
|
|
499
|
+
filter_query=""
|
|
500
|
+
applied_query=""
|
|
501
|
+
top_index=0
|
|
502
|
+
cursor_pos=0
|
|
503
|
+
rebuild_view
|
|
504
|
+
continue
|
|
505
|
+
fi
|
|
506
|
+
cleanup
|
|
507
|
+
return 1
|
|
508
|
+
;;
|
|
509
|
+
"UP")
|
|
510
|
+
if [[ ${#view_indices[@]} -eq 0 ]]; then
|
|
511
|
+
:
|
|
512
|
+
elif [[ $cursor_pos -gt 0 ]]; then
|
|
513
|
+
((cursor_pos--))
|
|
514
|
+
elif [[ $top_index -gt 0 ]]; then
|
|
515
|
+
((top_index--))
|
|
516
|
+
fi
|
|
517
|
+
;;
|
|
518
|
+
"DOWN")
|
|
519
|
+
if [[ ${#view_indices[@]} -eq 0 ]]; then
|
|
520
|
+
:
|
|
521
|
+
else
|
|
522
|
+
local absolute_index=$((top_index + cursor_pos))
|
|
523
|
+
local last_index=$((${#view_indices[@]} - 1))
|
|
524
|
+
if [[ $absolute_index -lt $last_index ]]; then
|
|
525
|
+
local visible_count=$((${#view_indices[@]} - top_index))
|
|
526
|
+
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
|
|
527
|
+
|
|
528
|
+
if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then
|
|
529
|
+
((cursor_pos++))
|
|
530
|
+
elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then
|
|
531
|
+
((top_index++))
|
|
532
|
+
visible_count=$((${#view_indices[@]} - top_index))
|
|
533
|
+
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
|
|
534
|
+
if [[ $cursor_pos -ge $visible_count ]]; then
|
|
535
|
+
cursor_pos=$((visible_count - 1))
|
|
536
|
+
fi
|
|
537
|
+
fi
|
|
538
|
+
fi
|
|
539
|
+
fi
|
|
540
|
+
;;
|
|
541
|
+
"SPACE")
|
|
542
|
+
local idx=$((top_index + cursor_pos))
|
|
543
|
+
if [[ $idx -lt ${#view_indices[@]} ]]; then
|
|
544
|
+
local real="${view_indices[idx]}"
|
|
545
|
+
if [[ ${selected[real]} == true ]]; then
|
|
546
|
+
selected[real]=false
|
|
547
|
+
else
|
|
548
|
+
selected[real]=true
|
|
549
|
+
fi
|
|
550
|
+
fi
|
|
551
|
+
;;
|
|
552
|
+
"RETRY")
|
|
553
|
+
# 'R' toggles reverse order (only if metadata available)
|
|
554
|
+
if [[ "$has_metadata" == "true" ]]; then
|
|
555
|
+
if [[ "$sort_reverse" == "true" ]]; then
|
|
556
|
+
sort_reverse="false"
|
|
557
|
+
else
|
|
558
|
+
sort_reverse="true"
|
|
559
|
+
fi
|
|
560
|
+
rebuild_view
|
|
561
|
+
fi
|
|
562
|
+
;;
|
|
563
|
+
"CHAR:s" | "CHAR:S")
|
|
564
|
+
if [[ "$filter_mode" == "true" ]]; then
|
|
565
|
+
local ch="${key#CHAR:}"
|
|
566
|
+
filter_query+="$ch"
|
|
567
|
+
elif [[ "$has_metadata" == "true" ]]; then
|
|
568
|
+
# Cycle sort mode (only if metadata available)
|
|
569
|
+
case "$sort_mode" in
|
|
570
|
+
date) sort_mode="name" ;;
|
|
571
|
+
name) sort_mode="size" ;;
|
|
572
|
+
size) sort_mode="date" ;;
|
|
573
|
+
esac
|
|
574
|
+
rebuild_view
|
|
575
|
+
fi
|
|
576
|
+
;;
|
|
577
|
+
"FILTER")
|
|
578
|
+
# Trigger filter mode with /
|
|
579
|
+
filter_mode="true"
|
|
580
|
+
export MOLE_READ_KEY_FORCE_CHAR=1
|
|
581
|
+
filter_query=""
|
|
582
|
+
top_index=0
|
|
583
|
+
cursor_pos=0
|
|
584
|
+
rebuild_view
|
|
585
|
+
;;
|
|
586
|
+
"CHAR:f" | "CHAR:F")
|
|
587
|
+
if [[ "$filter_mode" == "true" ]]; then
|
|
588
|
+
filter_query+="${key#CHAR:}"
|
|
589
|
+
fi
|
|
590
|
+
;;
|
|
591
|
+
"CHAR:r")
|
|
592
|
+
# lower-case r: behave like reverse when NOT in filter mode
|
|
593
|
+
if [[ "$filter_mode" == "true" ]]; then
|
|
594
|
+
filter_query+="r"
|
|
595
|
+
else
|
|
596
|
+
if [[ "$sort_reverse" == "true" ]]; then
|
|
597
|
+
sort_reverse="false"
|
|
598
|
+
else
|
|
599
|
+
sort_reverse="true"
|
|
600
|
+
fi
|
|
601
|
+
rebuild_view
|
|
602
|
+
fi
|
|
603
|
+
;;
|
|
604
|
+
"DELETE")
|
|
605
|
+
# Backspace filter
|
|
606
|
+
if [[ "$filter_mode" == "true" && -n "$filter_query" ]]; then
|
|
607
|
+
filter_query="${filter_query%?}"
|
|
608
|
+
fi
|
|
609
|
+
;;
|
|
610
|
+
CHAR:*)
|
|
611
|
+
if [[ "$filter_mode" == "true" ]]; then
|
|
612
|
+
local ch="${key#CHAR:}"
|
|
613
|
+
# Special handling for /: clear filter
|
|
614
|
+
if [[ "$ch" == "/" ]]; then
|
|
615
|
+
filter_query=""
|
|
616
|
+
rebuild_view
|
|
617
|
+
# avoid accidental leading spaces
|
|
618
|
+
elif [[ -n "$filter_query" || "$ch" != " " ]]; then
|
|
619
|
+
filter_query+="$ch"
|
|
620
|
+
fi
|
|
621
|
+
fi
|
|
622
|
+
;;
|
|
623
|
+
"ENTER")
|
|
624
|
+
if [[ "$filter_mode" == "true" ]]; then
|
|
625
|
+
applied_query="$filter_query"
|
|
626
|
+
filter_mode="false"
|
|
627
|
+
unset MOLE_READ_KEY_FORCE_CHAR
|
|
628
|
+
top_index=0
|
|
629
|
+
cursor_pos=0
|
|
630
|
+
|
|
631
|
+
searching="true"
|
|
632
|
+
draw_menu # paint "searching..."
|
|
633
|
+
drain_pending_input # drop any extra keypresses (e.g., double-Enter)
|
|
634
|
+
rebuild_view
|
|
635
|
+
searching="false"
|
|
636
|
+
draw_menu
|
|
637
|
+
continue
|
|
638
|
+
fi
|
|
639
|
+
# In normal mode: smart Enter behavior
|
|
640
|
+
# 1. Check if any items are already selected
|
|
641
|
+
local has_selection=false
|
|
642
|
+
for ((i = 0; i < total_items; i++)); do
|
|
643
|
+
if [[ ${selected[i]} == true ]]; then
|
|
644
|
+
has_selection=true
|
|
645
|
+
break
|
|
646
|
+
fi
|
|
647
|
+
done
|
|
648
|
+
|
|
649
|
+
# 2. If nothing selected, auto-select current item
|
|
650
|
+
if [[ $has_selection == false ]]; then
|
|
651
|
+
local idx=$((top_index + cursor_pos))
|
|
652
|
+
if [[ $idx -lt ${#view_indices[@]} ]]; then
|
|
653
|
+
local real="${view_indices[idx]}"
|
|
654
|
+
selected[real]=true
|
|
655
|
+
fi
|
|
656
|
+
fi
|
|
657
|
+
|
|
658
|
+
# 3. Confirm and exit with current selections
|
|
659
|
+
local -a selected_indices=()
|
|
660
|
+
for ((i = 0; i < total_items; i++)); do
|
|
661
|
+
if [[ ${selected[i]} == true ]]; then
|
|
662
|
+
selected_indices+=("$i")
|
|
663
|
+
fi
|
|
664
|
+
done
|
|
665
|
+
|
|
666
|
+
local final_result=""
|
|
667
|
+
if [[ ${#selected_indices[@]} -gt 0 ]]; then
|
|
668
|
+
local IFS=','
|
|
669
|
+
final_result="${selected_indices[*]}"
|
|
670
|
+
fi
|
|
671
|
+
|
|
672
|
+
trap - EXIT INT TERM
|
|
673
|
+
MOLE_SELECTION_RESULT="$final_result"
|
|
674
|
+
restore_terminal
|
|
675
|
+
return 0
|
|
676
|
+
;;
|
|
677
|
+
"HELP")
|
|
678
|
+
# Removed help screen, users can explore the interface
|
|
679
|
+
;;
|
|
680
|
+
esac
|
|
681
|
+
done
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
# Export function for external use
|
|
685
|
+
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
686
|
+
echo "This is a library file. Source it from other scripts." >&2
|
|
687
|
+
exit 1
|
|
688
|
+
fi
|