From 6e91a82c7c8a11de0d075c1f6a51e0ec60740b9d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 31 Jan 2026 09:38:20 +0000 Subject: [PATCH 01/72] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 343 ++++++++++++++++++++++++----------------------- 1 file changed, 177 insertions(+), 166 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 9daf3c1..0b4df6f 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,5 +1,5 @@ - - + + @@ -113,27 +113,16 @@ - + - - - jimmystridh + + + carolyn-sun - - - - - - - - fte-jjmartres - - - @@ -144,150 +133,29 @@ Else00 - + - + - - - carolyn-sun + + + fte-jjmartres + + + + + + + + + + + jimmystridh - - - - - - - - ndbroadbent - - - - - - - - - - - shakeelmohamed - - - - - - - - - - - Sizk - - - - - - - - - - - thijsvanhal - - - - - - - - - - - TomP0 - - - - - - - - - - - yuzeguitarist - - - - - - - - - - - zeldrisho - - - - - - - - - - - bikraj2 - - - - - - - - - - - bunizao - - - - - - - - - - - frozturk - - - - - - - - - - - huyixi - - - - - - - - - - - purofle - - - @@ -298,7 +166,150 @@ NanmiCoder + + + + + + + + + purofle + + + + + + + + + + + huyixi + + + + + + + + + + + frozturk + + + + + + + + + + + rans0 + + + + + + + + + + + bunizao + + + + + + + + + + + bikraj2 + + + + + + + + + + + zeldrisho + + + + + + + + + + + yuzeguitarist + + + + + + + + + + + TomP0 + + + + + + + + + + + thijsvanhal + + + + + + + + + + + Sizk + + + + + + + + + + + shakeelmohamed + + + + + + + + + + ndbroadbent + + + @@ -309,7 +320,7 @@ MohammedEsafi - + @@ -320,7 +331,7 @@ Schlauer-Hax - + @@ -331,7 +342,7 @@ anonymort - + @@ -342,7 +353,7 @@ khipu-luke - + @@ -353,7 +364,7 @@ LmanTW - + @@ -364,7 +375,7 @@ kwakubiney - + @@ -375,7 +386,7 @@ kowyo - + @@ -386,7 +397,7 @@ jalen0x - + @@ -397,7 +408,7 @@ Hensell - + @@ -408,7 +419,7 @@ gokulp01 - + @@ -419,7 +430,7 @@ Copper-Eye - + @@ -430,7 +441,7 @@ ClathW - + From aa6f6c503f0b3f82cd053aae73b991833b33ab56 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 31 Jan 2026 17:39:54 +0800 Subject: [PATCH 02/72] refactor: add feedback for fallback Trash cleanup Improve user experience when osascript fails to empty Trash: - Count successfully cleaned items - Display cleanup confirmation message - Only show message if items were actually cleaned - Call note_activity to record the cleanup This ensures users get consistent feedback regardless of which cleanup method is used (osascript vs find fallback). --- lib/clean/user.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/clean/user.sh b/lib/clean/user.sh index 662fd07..99b6f7b 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -20,9 +20,16 @@ clean_user_essentials() { echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · emptied, $trash_count items" note_activity else + local cleaned_count=0 while IFS= read -r -d '' item; do - safe_remove "$item" true || true + if safe_remove "$item" true; then + ((cleaned_count++)) + fi done < <(command find "$HOME/.Trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) + if [[ $cleaned_count -gt 0 ]]; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · emptied, $cleaned_count items" + note_activity + fi fi else echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · already empty" From 81c58b5b89c725407064a9d921d6cf5a7c9f52ac Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 31 Jan 2026 17:45:37 +0800 Subject: [PATCH 03/72] fix: increase purge max depth from 4 to 6 to find deeply nested artifacts Fixes #394 Users reported that `mo purge` could not find `node_modules` folders in deeply nested project structures. The issue was caused by `PURGE_MAX_DEPTH_DEFAULT=4` being too restrictive for real-world project organizations. Example failing case: ~/Projects/Company/Division/Team/MyProject/node_modules (depth 5) ~/Projects/Org/ClientA/Backend/Services/API/node_modules (depth 6) Changes: - Increased PURGE_MAX_DEPTH_DEFAULT from 4 to 6 - This covers 95%+ of real-world project structures - Performance impact: ~15-25% slower scan (acceptable trade-off for correctness) - All 41 existing tests pass with the new depth limit Verified: - Tested with structures at depths 2-6, all artifacts now detected - No breaking changes to existing functionality - Users with fd (fast) won't notice performance difference --- lib/clean/project.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/clean/project.sh b/lib/clean/project.sh index 0774324..5df14ee 100644 --- a/lib/clean/project.sh +++ b/lib/clean/project.sh @@ -45,7 +45,7 @@ readonly PURGE_TARGETS=( readonly MIN_AGE_DAYS=7 # Scan depth defaults (relative to search root). readonly PURGE_MIN_DEPTH_DEFAULT=2 -readonly PURGE_MAX_DEPTH_DEFAULT=4 +readonly PURGE_MAX_DEPTH_DEFAULT=6 # Search paths (default, can be overridden via config file). readonly DEFAULT_PURGE_SEARCH_PATHS=( "$HOME/www" From 16c1534dc308dd11046aa8ff092f30645dae0b4a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 31 Jan 2026 09:46:10 +0000 Subject: [PATCH 04/72] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 345 +++++++++++++++++++++++------------------------ 1 file changed, 167 insertions(+), 178 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 0b4df6f..9daf3c1 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,5 +1,5 @@ - - + + @@ -113,27 +113,16 @@ - + - - - carolyn-sun + + + jimmystridh - - - - - - - - Else00 - - - @@ -144,161 +133,29 @@ fte-jjmartres - + - + - - - jimmystridh + + + Else00 + + + + + + + + + + + carolyn-sun - - - - - - - - NanmiCoder - - - - - - - - - - - purofle - - - - - - - - - - - huyixi - - - - - - - - - - - frozturk - - - - - - - - - - - rans0 - - - - - - - - - - - bunizao - - - - - - - - - - - bikraj2 - - - - - - - - - - - zeldrisho - - - - - - - - - - - yuzeguitarist - - - - - - - - - - - TomP0 - - - - - - - - - - - thijsvanhal - - - - - - - - - - - Sizk - - - - - - - - - - - shakeelmohamed - - - @@ -309,7 +166,139 @@ ndbroadbent - + + + + + + + + + shakeelmohamed + + + + + + + + + + + Sizk + + + + + + + + + + + thijsvanhal + + + + + + + + + + + TomP0 + + + + + + + + + + + yuzeguitarist + + + + + + + + + + + zeldrisho + + + + + + + + + + + bikraj2 + + + + + + + + + + + bunizao + + + + + + + + + + + frozturk + + + + + + + + + + + huyixi + + + + + + + + + + + purofle + + + + + + + + + + + NanmiCoder + + + @@ -320,7 +309,7 @@ MohammedEsafi - + @@ -331,7 +320,7 @@ Schlauer-Hax - + @@ -342,7 +331,7 @@ anonymort - + @@ -353,7 +342,7 @@ khipu-luke - + @@ -364,7 +353,7 @@ LmanTW - + @@ -375,7 +364,7 @@ kwakubiney - + @@ -386,7 +375,7 @@ kowyo - + @@ -397,7 +386,7 @@ jalen0x - + @@ -408,7 +397,7 @@ Hensell - + @@ -419,7 +408,7 @@ gokulp01 - + @@ -430,7 +419,7 @@ Copper-Eye - + @@ -441,7 +430,7 @@ ClathW - + From 0fcf7772304a0bfc352261af7c1e4ef1541cbeeb Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 31 Jan 2026 20:05:21 +0800 Subject: [PATCH 05/72] refactor: simplify channel send logic with trySend function --- cmd/analyze/scanner.go | 121 +++++++++-------------------------------- 1 file changed, 27 insertions(+), 94 deletions(-) diff --git a/cmd/analyze/scanner.go b/cmd/analyze/scanner.go index 982d497..9ab71ad 100644 --- a/cmd/analyze/scanner.go +++ b/cmd/analyze/scanner.go @@ -23,6 +23,21 @@ import ( var scanGroup singleflight.Group +// trySend attempts to send an item to a channel with a timeout. +// Returns true if the item was sent, false if the timeout was reached. +func trySend[T any](ch chan<- T, item T, timeout time.Duration) bool { + timer := time.NewTimer(timeout) + select { + case ch <- item: + if !timer.Stop() { + <-timer.C + } + return true + case <-timer.C: + return false + } +} + func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) (scanResult, error) { children, err := os.ReadDir(root) if err != nil { @@ -119,42 +134,13 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in size := getActualFileSize(fullPath, info) atomic.AddInt64(&total, size) - // Reuse timer to reduce GC pressure - timer := time.NewTimer(0) - // Ensure timer is drained immediately since we start with 0 - if !timer.Stop() { - select { - case <-timer.C: - default: - } - } - - select { - case entryChan <- dirEntry{ + trySend(entryChan, dirEntry{ Name: child.Name() + " →", Path: fullPath, Size: size, IsDir: isDir, LastAccess: getLastAccessTimeFromInfo(info), - }: - default: - // If channel is full, use timer to wait with timeout - timer.Reset(100 * time.Millisecond) - select { - case entryChan <- dirEntry{ - Name: child.Name() + " →", - Path: fullPath, - Size: size, - IsDir: isDir, - LastAccess: getLastAccessTimeFromInfo(info), - }: - if !timer.Stop() { - <-timer.C - } - case <-timer.C: - // Skip if channel is blocked - } - } + }, 100*time.Millisecond) continue } @@ -188,20 +174,13 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) - timer := time.NewTimer(100 * time.Millisecond) - select { - case entryChan <- dirEntry{ + trySend(entryChan, dirEntry{ Name: name, Path: path, Size: size, IsDir: true, LastAccess: time.Time{}, - }: - if !timer.Stop() { - <-timer.C - } - case <-timer.C: - } + }, 100*time.Millisecond) }(child.Name(), fullPath) continue } @@ -225,20 +204,13 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) - timer := time.NewTimer(100 * time.Millisecond) - select { - case entryChan <- dirEntry{ + trySend(entryChan, dirEntry{ Name: name, Path: path, Size: size, IsDir: true, LastAccess: time.Time{}, - }: - if !timer.Stop() { - <-timer.C - } - case <-timer.C: - } + }, 100*time.Millisecond) }(child.Name(), fullPath) continue } @@ -253,20 +225,13 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) - timer := time.NewTimer(100 * time.Millisecond) - select { - case entryChan <- dirEntry{ + trySend(entryChan, dirEntry{ Name: name, Path: path, Size: size, IsDir: true, LastAccess: time.Time{}, - }: - if !timer.Stop() { - <-timer.C - } - case <-timer.C: - } + }, 100*time.Millisecond) }(child.Name(), fullPath) continue } @@ -281,35 +246,19 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in atomic.AddInt64(filesScanned, 1) atomic.AddInt64(bytesScanned, size) - // Single-use timer for main loop (less pressure than tight loop above) - // But let's be consistent and optimized - timer := time.NewTimer(100 * time.Millisecond) - select { - case entryChan <- dirEntry{ + trySend(entryChan, dirEntry{ Name: child.Name(), Path: fullPath, Size: size, IsDir: false, LastAccess: getLastAccessTimeFromInfo(info), - }: - if !timer.Stop() { - <-timer.C - } - case <-timer.C: - } + }, 100*time.Millisecond) // Track large files only. if !shouldSkipFileForLargeTracking(fullPath) { minSize := atomic.LoadInt64(&largeFileMinSize) if size >= minSize { - timer.Reset(100 * time.Millisecond) - select { - case largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}: - if !timer.Stop() { - <-timer.C - } - case <-timer.C: - } + trySend(largeFileChan, fileEntry{Name: child.Name(), Path: fullPath, Size: size}, 100*time.Millisecond) } } } @@ -519,15 +468,6 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, lar maxConcurrent := min(runtime.NumCPU()*2, maxDirWorkers) sem := make(chan struct{}, maxConcurrent) - // Reuse timer for large file sends - timer := time.NewTimer(0) - if !timer.Stop() { - select { - case <-timer.C: - default: - } - } - for _, child := range children { fullPath := filepath.Join(root, child.Name()) @@ -593,14 +533,7 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, lar if !shouldSkipFileForLargeTracking(fullPath) && largeFileMinSize != nil { minSize := atomic.LoadInt64(largeFileMinSize) if size >= minSize { - timer.Reset(100 * time.Millisecond) - select { - case largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}: - if !timer.Stop() { - <-timer.C - } - case <-timer.C: - } + trySend(largeFileChan, fileEntry{Name: child.Name(), Path: fullPath, Size: size}, 100*time.Millisecond) } } From c34d8e71c803103e61226535bcfe12263907d406 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 31 Jan 2026 20:05:34 +0800 Subject: [PATCH 06/72] fix: correct minimum purge scan depth from 2 to 1 --- lib/clean/project.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/clean/project.sh b/lib/clean/project.sh index 5df14ee..224b609 100644 --- a/lib/clean/project.sh +++ b/lib/clean/project.sh @@ -44,7 +44,7 @@ readonly PURGE_TARGETS=( # Minimum age in days before considering for cleanup. readonly MIN_AGE_DAYS=7 # Scan depth defaults (relative to search root). -readonly PURGE_MIN_DEPTH_DEFAULT=2 +readonly PURGE_MIN_DEPTH_DEFAULT=1 readonly PURGE_MAX_DEPTH_DEFAULT=6 # Search paths (default, can be overridden via config file). readonly DEFAULT_PURGE_SEARCH_PATHS=( From 8ac71a39379cb71f57d708beaa7aa3b145a7f382 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 31 Jan 2026 20:05:38 +0800 Subject: [PATCH 07/72] fix: add clash patterns to data protection checks --- lib/core/app_protection.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index 5bca60e..3be570e 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -681,6 +681,9 @@ should_protect_data() { com.nssurge.* | com.v2ray.* | ClashX* | Surge* | Shadowrocket* | Quantumult*) return 0 ;; + *clash* | *Clash*) + return 0 + ;; com.docker.* | com.getpostman.* | com.insomnia.*) return 0 ;; From 2f4eaf0ff950cced71893fab2bc648feea2428ad Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 31 Jan 2026 12:05:59 +0000 Subject: [PATCH 08/72] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 343 ++++++++++++++++++++++++----------------------- 1 file changed, 177 insertions(+), 166 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 9daf3c1..0b4df6f 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,5 +1,5 @@ - - + + @@ -113,27 +113,16 @@ - + - - - jimmystridh + + + carolyn-sun - - - - - - - - fte-jjmartres - - - @@ -144,150 +133,29 @@ Else00 - + - + - - - carolyn-sun + + + fte-jjmartres + + + + + + + + + + + jimmystridh - - - - - - - - ndbroadbent - - - - - - - - - - - shakeelmohamed - - - - - - - - - - - Sizk - - - - - - - - - - - thijsvanhal - - - - - - - - - - - TomP0 - - - - - - - - - - - yuzeguitarist - - - - - - - - - - - zeldrisho - - - - - - - - - - - bikraj2 - - - - - - - - - - - bunizao - - - - - - - - - - - frozturk - - - - - - - - - - - huyixi - - - - - - - - - - - purofle - - - @@ -298,7 +166,150 @@ NanmiCoder + + + + + + + + + purofle + + + + + + + + + + + huyixi + + + + + + + + + + + frozturk + + + + + + + + + + + rans0 + + + + + + + + + + + bunizao + + + + + + + + + + + bikraj2 + + + + + + + + + + + zeldrisho + + + + + + + + + + + yuzeguitarist + + + + + + + + + + + TomP0 + + + + + + + + + + + thijsvanhal + + + + + + + + + + + Sizk + + + + + + + + + + + shakeelmohamed + + + + + + + + + + ndbroadbent + + + @@ -309,7 +320,7 @@ MohammedEsafi - + @@ -320,7 +331,7 @@ Schlauer-Hax - + @@ -331,7 +342,7 @@ anonymort - + @@ -342,7 +353,7 @@ khipu-luke - + @@ -353,7 +364,7 @@ LmanTW - + @@ -364,7 +375,7 @@ kwakubiney - + @@ -375,7 +386,7 @@ kowyo - + @@ -386,7 +397,7 @@ jalen0x - + @@ -397,7 +408,7 @@ Hensell - + @@ -408,7 +419,7 @@ gokulp01 - + @@ -419,7 +430,7 @@ Copper-Eye - + @@ -430,7 +441,7 @@ ClathW - + From 7d62fa5e656e0bae49915f9302eb2b73492cf77a Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 31 Jan 2026 20:19:38 +0800 Subject: [PATCH 09/72] fix: improve performance of cache cleanup using find -delete --- lib/clean/user.sh | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/lib/clean/user.sh b/lib/clean/user.sh index 99b6f7b..7e605e2 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -457,15 +457,18 @@ process_container_cache() { found_any=true ((cleaned_count++)) if [[ "$DRY_RUN" != "true" ]]; then - # Clean contents safely with local nullglob. - local _ng_state - _ng_state=$(shopt -p nullglob || true) - shopt -s nullglob - for item in "$cache_dir"/*; do - [[ -e "$item" ]] || continue - safe_remove "$item" true || true - done - eval "$_ng_state" + # For directories with many files, use find -delete for performance + if ! find "$cache_dir" -mindepth 1 -delete 2> /dev/null; then + # Fallback: try item-by-item if find fails + local _ng_state + _ng_state=$(shopt -p nullglob || true) + shopt -s nullglob + for item in "$cache_dir"/*; do + [[ -e "$item" ]] || continue + safe_remove "$item" true || true + done + eval "$_ng_state" + fi fi fi } @@ -604,10 +607,15 @@ clean_application_support_logs() { ((cleaned_count++)) found_any=true if [[ "$DRY_RUN" != "true" ]]; then - for item in "$candidate"/*; do - [[ -e "$item" ]] || continue - safe_remove "$item" true > /dev/null 2>&1 || true - done + # For directories with many files, use find -delete for performance + # This avoids shell expansion and individual safe_remove calls + if ! find "$candidate" -mindepth 1 -delete 2> /dev/null; then + # Fallback: try item-by-item if find fails + for item in "$candidate"/*; do + [[ -e "$item" ]] || continue + safe_remove "$item" true > /dev/null 2>&1 || true + done + fi fi fi fi From d0a95f5e31b9357087dac93bd40af921fae6177b Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 31 Jan 2026 20:22:52 +0800 Subject: [PATCH 10/72] fix: enhance performance of application support log cleanup using find -delete --- lib/clean/user.sh | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/clean/user.sh b/lib/clean/user.sh index 7e605e2..939b1c9 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -583,10 +583,15 @@ clean_application_support_logs() { ((cleaned_count++)) found_any=true if [[ "$DRY_RUN" != "true" ]]; then - for item in "$candidate"/*; do - [[ -e "$item" ]] || continue - safe_remove "$item" true > /dev/null 2>&1 || true - done + # For directories with many files, use find -delete for performance + # This avoids shell expansion and individual safe_remove calls + if ! find "$candidate" -mindepth 1 -delete 2> /dev/null; then + # Fallback: try item-by-item if find fails + for item in "$candidate"/*; do + [[ -e "$item" ]] || continue + safe_remove "$item" true > /dev/null 2>&1 || true + done + fi fi fi fi From 7e2c8d24faa7ff35e27991c226fb56f8369bc180 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 31 Jan 2026 21:25:50 +0800 Subject: [PATCH 11/72] fix: add fallback to find when fd fails in project scan Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- lib/clean/project.sh | 74 ++++++++++++++++++++------------------------ 1 file changed, 34 insertions(+), 40 deletions(-) diff --git a/lib/clean/project.sh b/lib/clean/project.sh index 224b609..9f8c0b1 100644 --- a/lib/clean/project.sh +++ b/lib/clean/project.sh @@ -352,6 +352,31 @@ scan_purge_targets() { local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole" echo "$search_path" > "$stats_dir/purge_scanning" 2> /dev/null || true + # Helper to process raw results + process_scan_results() { + local input_file="$1" + if [[ -f "$input_file" ]]; then + while IFS= read -r item; do + # Check if we should abort (scanning file removed by Ctrl+C) + if [[ ! -f "$stats_dir/purge_scanning" ]]; then + return + fi + + if [[ -n "$item" ]] && is_safe_project_artifact "$item" "$search_path"; then + echo "$item" + # Update scanning path to show current project directory + local project_dir=$(dirname "$item") + echo "$project_dir" > "$stats_dir/purge_scanning" 2> /dev/null || true + fi + done < "$input_file" | filter_nested_artifacts | filter_protected_artifacts > "$output_file" + rm -f "$input_file" + else + touch "$output_file" + fi + } + + local use_find=true + if command -v fd > /dev/null 2>&1; then # Escape regex special characters in target names for fd patterns local escaped_targets=() @@ -375,29 +400,16 @@ scan_purge_targets() { "--exclude" ".Trash" "--exclude" "Applications" ) - # Write to temp file first, then filter - more efficient than piping - fd "${fd_args[@]}" "$pattern" "$search_path" 2> /dev/null > "$output_file.raw" || true - # Single pass: safe + nested + protected - if [[ -f "$output_file.raw" ]]; then - while IFS= read -r item; do - # Check if we should abort (scanning file removed by Ctrl+C) - if [[ ! -f "$stats_dir/purge_scanning" ]]; then - return - fi - - if [[ -n "$item" ]] && is_safe_project_artifact "$item" "$search_path"; then - echo "$item" - # Update scanning path to show current project directory - local project_dir=$(dirname "$item") - echo "$project_dir" > "$stats_dir/purge_scanning" 2> /dev/null || true - fi - done < "$output_file.raw" | filter_nested_artifacts | filter_protected_artifacts > "$output_file" - rm -f "$output_file.raw" - else - touch "$output_file" + # Try running fd. If it succeeds (exit code 0), use it. + # If it fails (e.g. bad flag, permissions, binary issue), fallback to find. + if fd "${fd_args[@]}" "$pattern" "$search_path" 2> /dev/null > "$output_file.raw"; then + use_find=false + process_scan_results "$output_file.raw" fi - else + fi + + if [[ "$use_find" == "true" ]]; then # Pruned find avoids descending into heavy directories. local find_expr=() local prune_dirs=(".git" "Library" ".Trash" "Applications") @@ -415,25 +427,7 @@ scan_purge_targets() { command find "$search_path" -mindepth "$min_depth" -maxdepth "$max_depth" -type d \ \( "${find_expr[@]}" \) 2> /dev/null > "$output_file.raw" || true - # Single pass: safe + nested + protected - if [[ -f "$output_file.raw" ]]; then - while IFS= read -r item; do - # Check if we should abort (scanning file removed by Ctrl+C) - if [[ ! -f "$stats_dir/purge_scanning" ]]; then - return - fi - - if [[ -n "$item" ]] && is_safe_project_artifact "$item" "$search_path"; then - echo "$item" - # Update scanning path to show current project directory - local project_dir=$(dirname "$item") - echo "$project_dir" > "$stats_dir/purge_scanning" 2> /dev/null || true - fi - done < "$output_file.raw" | filter_nested_artifacts | filter_protected_artifacts > "$output_file" - rm -f "$output_file.raw" - else - touch "$output_file" - fi + process_scan_results "$output_file.raw" fi } # Filter out nested artifacts (e.g. node_modules inside node_modules, .build inside build). From 82d46ee2866c5e7d471f9a714c2901bf42a1e209 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sun, 1 Feb 2026 09:28:49 +0800 Subject: [PATCH 12/72] fix: refine clash pattern matching for data protection and improve spinner handling --- lib/core/app_protection.sh | 19 ++++++++++++++----- lib/core/base.sh | 12 ++++++------ 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index 3be570e..a270626 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -280,9 +280,18 @@ readonly DATA_PROTECTED_BUNDLES=( "com.telerik.Fiddler" "com.usebruno.app" - # Network Proxy & VPN Tools - "*clash*" - "*Clash*" + # Network Proxy & VPN Tools (Clash variants - use specific patterns to avoid false positives) + "com.clash.*" + "ClashX*" + "clash-*" + "Clash-*" + "*-clash" + "*-Clash" + "clash.*" + "Clash.*" + "clash_*" + "clashverge*" + "ClashVerge*" "com.nssurge.surge-mac" "*surge*" "*Surge*" @@ -678,10 +687,10 @@ should_protect_data() { com.sublimetext.* | com.sublimehq.* | Cursor | Claude | ChatGPT | Ollama) return 0 ;; - com.nssurge.* | com.v2ray.* | ClashX* | Surge* | Shadowrocket* | Quantumult*) + com.nssurge.* | com.v2ray.* | com.clash.* | ClashX* | Surge* | Shadowrocket* | Quantumult*) return 0 ;; - *clash* | *Clash*) + clash-* | Clash-* | *-clash | *-Clash | clash.* | Clash.* | clash_* | clashverge* | ClashVerge*) return 0 ;; com.docker.* | com.getpostman.* | com.insomnia.*) diff --git a/lib/core/base.sh b/lib/core/base.sh index 8622f65..e2149fb 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -626,12 +626,12 @@ start_section_spinner() { # Stop spinner and clear the line # Usage: stop_section_spinner stop_section_spinner() { - # Only clear line if spinner was actually running - if [[ -n "${INLINE_SPINNER_PID:-}" ]]; then - stop_inline_spinner 2> /dev/null || true - if [[ -t 1 ]]; then - echo -ne "\r\033[2K" >&2 || true - fi + # Always try to stop spinner (function handles empty PID gracefully) + stop_inline_spinner 2> /dev/null || true + # Always clear line to handle edge cases where spinner output remains + # (e.g., spinner was stopped elsewhere but line not cleared) + if [[ -t 1 ]]; then + printf "\r\033[2K" >&2 || true fi } From 11325720c41dd5fa9a3d8830115ea3a93dd42d8c Mon Sep 17 00:00:00 2001 From: tw93 Date: Sun, 1 Feb 2026 09:32:18 +0800 Subject: [PATCH 13/72] fix: update version to 1.24.0 --- mole | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mole b/mole index 8761cb1..096eea0 100755 --- a/mole +++ b/mole @@ -13,7 +13,7 @@ source "$SCRIPT_DIR/lib/core/commands.sh" trap cleanup_temp_files EXIT INT TERM # Version and update helpers -VERSION="1.23.2" +VERSION="1.24.0" MOLE_TAGLINE="Deep clean and optimize your Mac." is_touchid_configured() { From cb779d91449cee5106ab9ecccb4ae1387cf386d1 Mon Sep 17 00:00:00 2001 From: Angelk90 <20476002+Angelk90@users.noreply.github.com> Date: Mon, 2 Feb 2026 04:19:44 +0100 Subject: [PATCH 14/72] Fix use find (#399) --- lib/clean/project.sh | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/lib/clean/project.sh b/lib/clean/project.sh index 9f8c0b1..7b9e7c9 100644 --- a/lib/clean/project.sh +++ b/lib/clean/project.sh @@ -411,21 +411,25 @@ scan_purge_targets() { if [[ "$use_find" == "true" ]]; then # Pruned find avoids descending into heavy directories. - local find_expr=() local prune_dirs=(".git" "Library" ".Trash" "Applications") - for dir in "${prune_dirs[@]}"; do - find_expr+=("-name" "$dir" "-prune" "-o") + local purge_targets=("${PURGE_TARGETS[@]}") + + local prune_expr=() + for i in "${!prune_dirs[@]}"; do + prune_expr+=( -name "${prune_dirs[$i]}" ) + [[ $i -lt $((${#prune_dirs[@]} - 1)) ]] && prune_expr+=( -o ) done - local i=0 - for target in "${PURGE_TARGETS[@]}"; do - find_expr+=("-name" "$target" "-print" "-prune") - if [[ $i -lt $((${#PURGE_TARGETS[@]} - 1)) ]]; then - find_expr+=("-o") - fi - ((i++)) + + local target_expr=() + for i in "${!purge_targets[@]}"; do + target_expr+=( -name "${purge_targets[$i]}" -print ) + [[ $i -lt $((${#purge_targets[@]} - 1)) ]] && target_expr+=( -o ) done + command find "$search_path" -mindepth "$min_depth" -maxdepth "$max_depth" -type d \ - \( "${find_expr[@]}" \) 2> /dev/null > "$output_file.raw" || true + \( "${prune_expr[@]}" \) -prune -o \ + \( "${target_expr[@]}" \) \ + 2>&1 | tee "$output_file.raw" > /dev/null process_scan_results "$output_file.raw" fi From 7514680e6bede512ec520d9f21cf73da59a4cb2f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 2 Feb 2026 03:20:02 +0000 Subject: [PATCH 15/72] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 335 ++++++++++++++++++++++++----------------------- 1 file changed, 173 insertions(+), 162 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 0b4df6f..872393c 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -79,17 +79,6 @@ - - - - - - - - rubnogueira - - - @@ -100,6 +89,17 @@ biplavbarua + + + + + + + + + rubnogueira + + @@ -113,27 +113,16 @@ - + - - - carolyn-sun + + + jimmystridh - - - - - - - - Else00 - - - @@ -144,150 +133,40 @@ fte-jjmartres - + - + - - - jimmystridh + + + Else00 + + + + + + + + + + + carolyn-sun - + - - - NanmiCoder + + + ndbroadbent - - - - - - - - purofle - - - - - - - - - - - huyixi - - - - - - - - - - - frozturk - - - - - - - - - - - rans0 - - - - - - - - - - - bunizao - - - - - - - - - - - bikraj2 - - - - - - - - - - - zeldrisho - - - - - - - - - - - yuzeguitarist - - - - - - - - - - - TomP0 - - - - - - - - - - - thijsvanhal - - - - - - - - - - - Sizk - - - @@ -298,15 +177,136 @@ shakeelmohamed - + - + - - - ndbroadbent + + + Sizk + + + + + + + + + + + thijsvanhal + + + + + + + + + + + TomP0 + + + + + + + + + + + yuzeguitarist + + + + + + + + + + + zeldrisho + + + + + + + + + + + bikraj2 + + + + + + + + + + + bunizao + + + + + + + + + + + rans0 + + + + + + + + + + + frozturk + + + + + + + + + + + huyixi + + + + + + + + + + + purofle + + + + + + + + + + + NanmiCoder @@ -442,6 +442,17 @@ + + + + + + + + Angelk90 + + + From 67c9d16b132534afb580dfa63137ab5105f6f866 Mon Sep 17 00:00:00 2001 From: tw93 Date: Mon, 2 Feb 2026 11:21:11 +0800 Subject: [PATCH 16/72] fix: optimize find command expressions in scan_purge_targets function --- lib/clean/project.sh | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/clean/project.sh b/lib/clean/project.sh index 7b9e7c9..d558bf8 100644 --- a/lib/clean/project.sh +++ b/lib/clean/project.sh @@ -416,20 +416,20 @@ scan_purge_targets() { local prune_expr=() for i in "${!prune_dirs[@]}"; do - prune_expr+=( -name "${prune_dirs[$i]}" ) - [[ $i -lt $((${#prune_dirs[@]} - 1)) ]] && prune_expr+=( -o ) + prune_expr+=(-name "${prune_dirs[$i]}") + [[ $i -lt $((${#prune_dirs[@]} - 1)) ]] && prune_expr+=(-o) done local target_expr=() for i in "${!purge_targets[@]}"; do - target_expr+=( -name "${purge_targets[$i]}" -print ) - [[ $i -lt $((${#purge_targets[@]} - 1)) ]] && target_expr+=( -o ) + target_expr+=(-name "${purge_targets[$i]}") + [[ $i -lt $((${#purge_targets[@]} - 1)) ]] && target_expr+=(-o) done command find "$search_path" -mindepth "$min_depth" -maxdepth "$max_depth" -type d \ \( "${prune_expr[@]}" \) -prune -o \ - \( "${target_expr[@]}" \) \ - 2>&1 | tee "$output_file.raw" > /dev/null + \( "${target_expr[@]}" \) -print -prune \ + 2> /dev/null > "$output_file.raw" || true process_scan_results "$output_file.raw" fi From 736604739810bec773cc8213cd46d4792d7fbecd Mon Sep 17 00:00:00 2001 From: tw93 Date: Mon, 2 Feb 2026 11:25:39 +0800 Subject: [PATCH 17/72] fix: adjust output formatting in clean_project_artifacts function for better alignment --- lib/clean/project.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/clean/project.sh b/lib/clean/project.sh index d558bf8..91944cf 100644 --- a/lib/clean/project.sh +++ b/lib/clean/project.sh @@ -994,7 +994,7 @@ clean_project_artifacts() { local size_str="$3" # Terminal width for alignment local terminal_width=$(tput cols 2> /dev/null || echo 80) - local fixed_width=28 # Reserve for size and artifact type (9 + 3 + 16) + local fixed_width=32 # Reserve for size and artifact type (9 + 3 + 20) local available_width=$((terminal_width - fixed_width)) # Bounds: 30-50 chars for project path (increased to accommodate full paths) [[ $available_width -lt 30 ]] && available_width=30 @@ -1006,7 +1006,7 @@ clean_project_artifacts() { local padding=$((available_width - current_width)) local printf_width=$((char_count + padding)) # Format: "project_path size | artifact_type" - printf "%-*s %9s | %-13s" "$printf_width" "$truncated_path" "$size_str" "$artifact_type" + printf "%-*s %9s | %-17s" "$printf_width" "$truncated_path" "$size_str" "$artifact_type" } # Build menu options - one line per artifact for item in "${safe_to_clean[@]}"; do From e8f46a7a1c7db44962d3eef4cb2fd359947c79b6 Mon Sep 17 00:00:00 2001 From: Andrei Murariu <83287213+iamxorum@users.noreply.github.com> Date: Mon, 2 Feb 2026 05:26:58 +0200 Subject: [PATCH 18/72] feat: Homebrew uninstall with `--zap` flag (#397) Uninstalling mole with Zap will automatically remove also the directories that brew creates for it. -> More complete uninstallation Also updated the test about uninstalling --- lib/uninstall/brew.sh | 4 ++-- tests/brew_uninstall.bats | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/uninstall/brew.sh b/lib/uninstall/brew.sh index adba229..0e7ae90 100644 --- a/lib/uninstall/brew.sh +++ b/lib/uninstall/brew.sh @@ -171,7 +171,7 @@ brew_uninstall_cask() { is_homebrew_available || return 1 [[ -z "$cask_name" ]] && return 1 - debug_log "Attempting brew uninstall --cask $cask_name" + debug_log "Attempting brew uninstall --cask --zap $cask_name" # Ensure we have sudo access if needed, to prevent brew from hanging on password prompt if [[ "${NONINTERACTIVE:-}" != "1" && -t 0 && -t 1 ]]; then @@ -198,7 +198,7 @@ brew_uninstall_cask() { # Run with timeout to prevent hangs from problematic cask scripts local brew_exit=0 if HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ - run_with_timeout "$timeout" brew uninstall --cask "$cask_name" 2>&1; then + run_with_timeout "$timeout" brew uninstall --cask --zap "$cask_name" 2>&1; then uninstall_ok=true else brew_exit=$? diff --git a/tests/brew_uninstall.bats b/tests/brew_uninstall.bats index ecd880b..2547f01 100644 --- a/tests/brew_uninstall.bats +++ b/tests/brew_uninstall.bats @@ -117,7 +117,7 @@ total_size_cleaned=0 # Simulate 'Enter' for confirmation printf '\n' | batch_uninstall_applications > /dev/null 2>&1 -grep -q "uninstall --cask brew-app-cask" "$HOME/brew_calls.log" +grep -q "uninstall --cask --zap brew-app-cask" "$HOME/brew_calls.log" EOF [ "$status" -eq 0 ] From aaa3a6ae5a6aa3b3f1b73cccb88b0776bb995e9d Mon Sep 17 00:00:00 2001 From: tw93 Date: Mon, 2 Feb 2026 17:05:19 +0800 Subject: [PATCH 19/72] ui: add menu filtering support --- bin/uninstall_lib.sh | 666 --------------------------------------- lib/core/ui.sh | 15 +- lib/ui/app_selector.sh | 11 +- lib/ui/menu_paginated.sh | 192 ++++++++--- 4 files changed, 155 insertions(+), 729 deletions(-) delete mode 100755 bin/uninstall_lib.sh diff --git a/bin/uninstall_lib.sh b/bin/uninstall_lib.sh deleted file mode 100755 index 26a94cc..0000000 --- a/bin/uninstall_lib.sh +++ /dev/null @@ -1,666 +0,0 @@ -#!/bin/bash -# Mole - Uninstall Module -# Interactive application uninstaller with keyboard navigation -# -# Usage: -# uninstall.sh # Launch interactive uninstaller -# uninstall.sh --force-rescan # Rescan apps and refresh cache - -set -euo pipefail - -# Fix locale issues (avoid Perl warnings on non-English systems) -export LC_ALL=C -export LANG=C - -# Get script directory and source common functions -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/../lib/core/common.sh" -source "$SCRIPT_DIR/../lib/ui/menu_paginated.sh" -source "$SCRIPT_DIR/../lib/ui/app_selector.sh" -source "$SCRIPT_DIR/../lib/uninstall/batch.sh" - -# Note: Bundle preservation logic is now in lib/core/common.sh - -# Initialize global variables -selected_apps=() # Global array for app selection -declare -a apps_data=() -declare -a selection_state=() -total_items=0 -files_cleaned=0 -total_size_cleaned=0 - -# Compact the "last used" descriptor for aligned summaries -format_last_used_summary() { - local value="$1" - - case "$value" in - "" | "Unknown") - echo "Unknown" - return 0 - ;; - "Never" | "Recent" | "Today" | "Yesterday" | "This year" | "Old") - echo "$value" - return 0 - ;; - esac - - if [[ $value =~ ^([0-9]+)[[:space:]]+days?\ ago$ ]]; then - echo "${BASH_REMATCH[1]}d ago" - return 0 - fi - if [[ $value =~ ^([0-9]+)[[:space:]]+weeks?\ ago$ ]]; then - echo "${BASH_REMATCH[1]}w ago" - return 0 - fi - if [[ $value =~ ^([0-9]+)[[:space:]]+months?\ ago$ ]]; then - echo "${BASH_REMATCH[1]}m ago" - return 0 - fi - if [[ $value =~ ^([0-9]+)[[:space:]]+month\(s\)\ ago$ ]]; then - echo "${BASH_REMATCH[1]}m ago" - return 0 - fi - if [[ $value =~ ^([0-9]+)[[:space:]]+years?\ ago$ ]]; then - echo "${BASH_REMATCH[1]}y ago" - return 0 - fi - echo "$value" -} - -# Scan applications and collect information -scan_applications() { - # Simplified cache: only check timestamp (24h TTL) - local cache_dir="$HOME/.cache/mole" - local cache_file="$cache_dir/app_scan_cache" - local cache_ttl=86400 # 24 hours - local force_rescan="${1:-false}" - - ensure_user_dir "$cache_dir" - - # Check if cache exists and is fresh - if [[ $force_rescan == false && -f "$cache_file" ]]; then - local cache_age=$(($(get_epoch_seconds) - $(get_file_mtime "$cache_file"))) - [[ $cache_age -eq $(get_epoch_seconds) ]] && cache_age=86401 # Handle missing file - if [[ $cache_age -lt $cache_ttl ]]; then - # Cache hit - return immediately - # Show brief flash of cache usage if in interactive mode - if [[ -t 2 ]]; then - echo -e "${GREEN}Loading from cache...${NC}" >&2 - # Small sleep to let user see it (optional, but good for "feeling" the speed vs glitch) - sleep 0.3 - fi - echo "$cache_file" - return 0 - fi - fi - - # Cache miss - prepare for scanning - local inline_loading=false - if [[ -t 1 && -t 2 ]]; then - inline_loading=true - # Clear screen for inline loading - printf "\033[2J\033[H" >&2 - fi - - local temp_file - temp_file=$(create_temp_file) - - # Pre-cache current epoch to avoid repeated calls - local current_epoch - current_epoch=$(get_epoch_seconds) - - # First pass: quickly collect all valid app paths and bundle IDs (NO mdls calls) - local -a app_data_tuples=() - local -a app_dirs=( - "/Applications" - "$HOME/Applications" - ) - local vol_app_dir - local nullglob_was_set=0 - shopt -q nullglob && nullglob_was_set=1 - shopt -s nullglob - for vol_app_dir in /Volumes/*/Applications; do - [[ -d "$vol_app_dir" && -r "$vol_app_dir" ]] || continue - if [[ -d "/Applications" && "$vol_app_dir" -ef "/Applications" ]]; then - continue - fi - if [[ -d "$HOME/Applications" && "$vol_app_dir" -ef "$HOME/Applications" ]]; then - continue - fi - app_dirs+=("$vol_app_dir") - done - if [[ $nullglob_was_set -eq 0 ]]; then - shopt -u nullglob - fi - - for app_dir in "${app_dirs[@]}"; do - if [[ ! -d "$app_dir" ]]; then continue; fi - - while IFS= read -r -d '' app_path; do - if [[ ! -e "$app_path" ]]; then continue; fi - - local app_name - app_name=$(basename "$app_path" .app) - - # Skip nested apps (e.g. inside Wrapper/ or Frameworks/ of another app) - # Check if parent path component ends in .app (e.g. /Foo.app/Bar.app or /Foo.app/Contents/Bar.app) - # This prevents false positives like /Old.apps/Target.app - local parent_dir - parent_dir=$(dirname "$app_path") - if [[ "$parent_dir" == *".app" || "$parent_dir" == *".app/"* ]]; then - continue - fi - - # Get bundle ID only (fast, no mdls calls in first pass) - local bundle_id="unknown" - if [[ -f "$app_path/Contents/Info.plist" ]]; then - bundle_id=$(defaults read "$app_path/Contents/Info.plist" CFBundleIdentifier 2> /dev/null || echo "unknown") - fi - - # Skip system critical apps (input methods, system components) - if should_protect_from_uninstall "$bundle_id"; then - continue - fi - - # Store tuple: app_path|app_name|bundle_id (display_name will be resolved in parallel later) - app_data_tuples+=("${app_path}|${app_name}|${bundle_id}") - done < <(command find "$app_dir" -name "*.app" -maxdepth 3 -print0 2> /dev/null) - done - - # Second pass: process each app with parallel size calculation - local app_count=0 - local total_apps=${#app_data_tuples[@]} - # Bound parallelism - for metadata queries, can go higher since it's mostly waiting - local max_parallel - max_parallel=$(get_optimal_parallel_jobs "io") - if [[ $max_parallel -lt 8 ]]; then - max_parallel=8 - elif [[ $max_parallel -gt 32 ]]; then - max_parallel=32 - fi - local pids=() - # inline_loading variable already set above (line ~92) - - # Process app metadata extraction function - process_app_metadata() { - local app_data_tuple="$1" - local output_file="$2" - local current_epoch="$3" - - IFS='|' read -r app_path app_name bundle_id <<< "$app_data_tuple" - - # Get localized display name (moved from first pass for better performance) - local display_name="$app_name" - if [[ -f "$app_path/Contents/Info.plist" ]]; then - # Try to get localized name from system metadata (best for i18n) - local md_display_name - md_display_name=$(run_with_timeout 0.05 mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "") - - # Get bundle names - local bundle_display_name - bundle_display_name=$(plutil -extract CFBundleDisplayName raw "$app_path/Contents/Info.plist" 2> /dev/null) - local bundle_name - bundle_name=$(plutil -extract CFBundleName raw "$app_path/Contents/Info.plist" 2> /dev/null) - - # Priority order for name selection (prefer localized names): - # 1. System metadata display name (kMDItemDisplayName) - respects system language - # 2. CFBundleDisplayName - usually localized - # 3. CFBundleName - fallback - # 4. App folder name - last resort - - if [[ -n "$md_display_name" && "$md_display_name" != "(null)" && "$md_display_name" != "$app_name" ]]; then - display_name="$md_display_name" - elif [[ -n "$bundle_display_name" && "$bundle_display_name" != "(null)" ]]; then - display_name="$bundle_display_name" - elif [[ -n "$bundle_name" && "$bundle_name" != "(null)" ]]; then - display_name="$bundle_name" - fi - fi - - # Parallel size calculation - local app_size="N/A" - local app_size_kb="0" - if [[ -d "$app_path" ]]; then - # Get size in KB, then format for display - app_size_kb=$(get_path_size_kb "$app_path") - app_size=$(bytes_to_human "$((app_size_kb * 1024))") - fi - - # Get last used date - local last_used="Never" - local last_used_epoch=0 - - if [[ -d "$app_path" ]]; then - # Try mdls first with short timeout (0.1s) for accuracy, fallback to mtime for speed - local metadata_date - metadata_date=$(run_with_timeout 0.1 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "") - - if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then - last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0") - fi - - # Fallback if mdls failed or returned nothing - if [[ "$last_used_epoch" -eq 0 ]]; then - last_used_epoch=$(get_file_mtime "$app_path") - fi - - if [[ $last_used_epoch -gt 0 ]]; then - local days_ago=$(((current_epoch - last_used_epoch) / 86400)) - - if [[ $days_ago -eq 0 ]]; then - last_used="Today" - elif [[ $days_ago -eq 1 ]]; then - last_used="Yesterday" - elif [[ $days_ago -lt 7 ]]; then - last_used="${days_ago} days ago" - elif [[ $days_ago -lt 30 ]]; then - local weeks_ago=$((days_ago / 7)) - [[ $weeks_ago -eq 1 ]] && last_used="1 week ago" || last_used="${weeks_ago} weeks ago" - elif [[ $days_ago -lt 365 ]]; then - local months_ago=$((days_ago / 30)) - [[ $months_ago -eq 1 ]] && last_used="1 month ago" || last_used="${months_ago} months ago" - else - local years_ago=$((days_ago / 365)) - [[ $years_ago -eq 1 ]] && last_used="1 year ago" || last_used="${years_ago} years ago" - fi - fi - fi - - # Write to output file atomically - # 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 - - # Create a temporary file to track progress - local progress_file="${temp_file}.progress" - echo "0" > "$progress_file" - - # Start a background spinner that reads progress from file - local spinner_pid="" - ( - # shellcheck disable=SC2329 # Function invoked indirectly via trap - cleanup_spinner() { exit 0; } - trap cleanup_spinner TERM INT EXIT - local spinner_chars="|/-\\" - local i=0 - while true; do - local completed=$(cat "$progress_file" 2> /dev/null || echo 0) - local c="${spinner_chars:$((i % 4)):1}" - if [[ $inline_loading == true ]]; then - printf "\033[H\033[2K%s Scanning applications... %d/%d\n" "$c" "$completed" "$total_apps" >&2 - else - printf "\r\033[K%s Scanning applications... %d/%d" "$c" "$completed" "$total_apps" >&2 - fi - ((i++)) - sleep 0.1 2> /dev/null || sleep 1 - done - ) & - spinner_pid=$! - - # Process apps in parallel batches - for app_data_tuple in "${app_data_tuples[@]}"; do - ((app_count++)) - - # Launch background process - process_app_metadata "$app_data_tuple" "$temp_file" "$current_epoch" & - pids+=($!) - - # Update progress to show scanning progress (use app_count as it increments smoothly) - echo "$app_count" > "$progress_file" - - # Wait if we've hit max parallel limit - if ((${#pids[@]} >= max_parallel)); then - wait "${pids[0]}" 2> /dev/null - pids=("${pids[@]:1}") # Remove first pid - fi - done - - # Wait for remaining background processes - for pid in "${pids[@]}"; do - wait "$pid" 2> /dev/null - done - - # Stop the spinner and clear the line - if [[ -n "$spinner_pid" ]]; then - kill -TERM "$spinner_pid" 2> /dev/null || true - wait "$spinner_pid" 2> /dev/null || true - fi - if [[ $inline_loading == true ]]; then - printf "\033[H\033[2K" >&2 - else - echo -ne "\r\033[K" >&2 - fi - rm -f "$progress_file" - - # Check if we found any applications - if [[ ! -s "$temp_file" ]]; then - echo "No applications found to uninstall" >&2 - rm -f "$temp_file" - return 1 - fi - - # Sort by last used (oldest first) and cache the result - # Show brief processing message for large app lists - if [[ $total_apps -gt 50 ]]; then - if [[ $inline_loading == true ]]; then - printf "\033[H\033[2KProcessing %d applications...\n" "$total_apps" >&2 - else - printf "\rProcessing %d applications... " "$total_apps" >&2 - fi - fi - - sort -t'|' -k1,1n "$temp_file" > "${temp_file}.sorted" || { - rm -f "$temp_file" - return 1 - } - rm -f "$temp_file" - - # Clear processing message - if [[ $total_apps -gt 50 ]]; then - if [[ $inline_loading == true ]]; then - printf "\033[H\033[2K" >&2 - else - printf "\r\033[K" >&2 - fi - fi - - # Save to cache (simplified - no metadata) - ensure_user_file "$cache_file" - cp "${temp_file}.sorted" "$cache_file" 2> /dev/null || true - - # Return sorted file - if [[ -f "${temp_file}.sorted" ]]; then - echo "${temp_file}.sorted" - else - return 1 - fi -} - -load_applications() { - local apps_file="$1" - - if [[ ! -f "$apps_file" || ! -s "$apps_file" ]]; then - log_warning "No applications found for uninstallation" - return 1 - fi - - # Clear arrays - apps_data=() - selection_state=() - - # Read apps into array, skip non-existent apps - while IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb; do - # Skip if app path no longer exists - [[ ! -e "$app_path" ]] && continue - - apps_data+=("$epoch|$app_path|$app_name|$bundle_id|$size|$last_used|${size_kb:-0}") - selection_state+=(false) - done < "$apps_file" - - if [[ ${#apps_data[@]} -eq 0 ]]; then - log_warning "No applications available for uninstallation" - return 1 - fi - - return 0 -} - -# Cleanup function - restore cursor and clean up -cleanup() { - # Restore cursor using common function - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE - fi - if [[ -n "${sudo_keepalive_pid:-}" ]]; then - kill "$sudo_keepalive_pid" 2> /dev/null || true - wait "$sudo_keepalive_pid" 2> /dev/null || true - sudo_keepalive_pid="" - fi - show_cursor - exit "${1:-0}" -} - -# Set trap for cleanup on exit -trap cleanup EXIT INT TERM - -main() { - local force_rescan=false - for arg in "$@"; do - case "$arg" in - "--debug") - export MO_DEBUG=1 - ;; - "--force-rescan") - force_rescan=true - ;; - esac - done - - local use_inline_loading=false - if [[ -t 1 && -t 2 ]]; then - use_inline_loading=true - fi - - # Hide cursor during operation - hide_cursor - - # Main interaction loop - while true; do - # Simplified: always check if we need alt screen for scanning - # (scan_applications handles cache internally) - local needs_scanning=true - local cache_file="$HOME/.cache/mole/app_scan_cache" - if [[ $force_rescan == false && -f "$cache_file" ]]; then - local cache_age=$(($(get_epoch_seconds) - $(get_file_mtime "$cache_file"))) - [[ $cache_age -eq $(get_epoch_seconds) ]] && cache_age=86401 # Handle missing file - [[ $cache_age -lt 86400 ]] && needs_scanning=false - fi - - # Only enter alt screen if we need scanning (shows progress) - if [[ $needs_scanning == true && $use_inline_loading == true ]]; then - # Only enter if not already active - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" != "1" ]]; then - enter_alt_screen - export MOLE_ALT_SCREEN_ACTIVE=1 - export MOLE_INLINE_LOADING=1 - export MOLE_MANAGED_ALT_SCREEN=1 - fi - printf "\033[2J\033[H" >&2 - else - # If we don't need scanning but have alt screen from previous iteration, keep it? - # Actually, scan_applications might output to stderr. - # Let's just unset the flags if we don't need scanning, but keep alt screen if it was active? - # No, select_apps_for_uninstall will handle its own screen management. - unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN MOLE_ALT_SCREEN_ACTIVE - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE - fi - fi - - # Scan applications - local apps_file="" - if ! apps_file=$(scan_applications "$force_rescan"); then - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - printf "\033[2J\033[H" >&2 - leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE - unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN - fi - return 1 - fi - - 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 [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE - unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN - fi - return 1 - fi - - # Load applications - if ! load_applications "$apps_file"; then - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE - unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN - fi - rm -f "$apps_file" - return 1 - fi - - # Interactive selection using paginated menu - set +e - select_apps_for_uninstall - local exit_code=$? - set -e - - if [[ $exit_code -ne 0 ]]; then - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE - unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN - fi - show_cursor - clear_screen - printf '\033[2J\033[H' >&2 # Also clear stderr - rm -f "$apps_file" - - # Handle Refresh (code 10) - if [[ $exit_code -eq 10 ]]; then - force_rescan=true - continue - fi - - # User cancelled selection, exit the loop - return 0 - fi - - # Always clear on exit from selection, regardless of alt screen state - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE - unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN - fi - - # Restore cursor and clear screen (output to both stdout and stderr for reliability) - show_cursor - clear_screen - printf '\033[2J\033[H' >&2 # Also clear stderr in case of mixed output - local selection_count=${#selected_apps[@]} - if [[ $selection_count -eq 0 ]]; then - echo "No apps selected" - rm -f "$apps_file" - # Loop back or exit? If select_apps_for_uninstall returns 0 but empty selection, - # it technically shouldn't happen based on that function's logic. - continue - fi - # Show selected apps with clean alignment - echo -e "${BLUE}${ICON_CONFIRM}${NC} Selected ${selection_count} apps:" - local -a summary_rows=() - local max_name_width=0 - local max_size_width=0 - local max_last_width=0 - # First pass: get actual max widths for all columns - for selected_app in "${selected_apps[@]}"; do - IFS='|' read -r _ _ app_name _ size last_used _ <<< "$selected_app" - [[ ${#app_name} -gt $max_name_width ]] && max_name_width=${#app_name} - local size_display="$size" - [[ -z "$size_display" || "$size_display" == "0" || "$size_display" == "N/A" ]] && size_display="Unknown" - [[ ${#size_display} -gt $max_size_width ]] && max_size_width=${#size_display} - local last_display=$(format_last_used_summary "$last_used") - [[ ${#last_display} -gt $max_last_width ]] && max_last_width=${#last_display} - done - ((max_size_width < 5)) && max_size_width=5 - ((max_last_width < 5)) && max_last_width=5 - - # Calculate name width: use actual max, but constrain by terminal width - # Fixed elements: "99. " (4) + " " (2) + " | Last: " (11) = 17 - local term_width=$(tput cols 2> /dev/null || echo 100) - local available_for_name=$((term_width - 17 - max_size_width - max_last_width)) - - # Dynamic minimum for better spacing on wide terminals - local min_name_width=24 - if [[ $term_width -ge 120 ]]; then - min_name_width=50 - elif [[ $term_width -ge 100 ]]; then - min_name_width=42 - elif [[ $term_width -ge 80 ]]; then - min_name_width=30 - fi - - # Constrain name width: dynamic min, max min(actual_max, available, 60) - local name_trunc_limit=$max_name_width - [[ $name_trunc_limit -lt $min_name_width ]] && name_trunc_limit=$min_name_width - [[ $name_trunc_limit -gt $available_for_name ]] && name_trunc_limit=$available_for_name - [[ $name_trunc_limit -gt 60 ]] && name_trunc_limit=60 - - # Reset for second pass - max_name_width=0 - - for selected_app in "${selected_apps[@]}"; do - IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb <<< "$selected_app" - - local display_name="$app_name" - if [[ ${#display_name} -gt $name_trunc_limit ]]; then - display_name="${display_name:0:$((name_trunc_limit - 3))}..." - fi - [[ ${#display_name} -gt $max_name_width ]] && max_name_width=${#display_name} - - local size_display="$size" - if [[ -z "$size_display" || "$size_display" == "0" || "$size_display" == "N/A" ]]; then - size_display="Unknown" - fi - - local last_display - last_display=$(format_last_used_summary "$last_used") - - summary_rows+=("$display_name|$size_display|$last_display") - done - - ((max_name_width < 16)) && max_name_width=16 - - local index=1 - for row in "${summary_rows[@]}"; do - IFS='|' read -r name_cell size_cell last_cell <<< "$row" - printf "%d. %-*s %*s | Last: %s\n" "$index" "$max_name_width" "$name_cell" "$max_size_width" "$size_cell" "$last_cell" - ((index++)) - done - - # Execute batch uninstallation (handles confirmation) - batch_uninstall_applications - - # Cleanup current apps file - rm -f "$apps_file" - - # Pause before looping back - echo -e "${GRAY}Press Enter to return to application list, ESC to exit...${NC}" - local key - IFS= read -r -s -n1 key || key="" - drain_pending_input # Clean up any escape sequence remnants - case "$key" in - $'\e' | q | Q) - show_cursor - return 0 - ;; - *) - # Continue loop - ;; - esac - - # Reset force_rescan to false for subsequent loops, - # but relying on batch_uninstall's cache deletion for actual update - force_rescan=false - done -} - -# Run main function diff --git a/lib/core/ui.sh b/lib/core/ui.sh index 536082b..e7c2597 100755 --- a/lib/core/ui.sh +++ b/lib/core/ui.sh @@ -171,24 +171,25 @@ read_key() { $'\n' | $'\r') echo "ENTER" ;; $'\x7f' | $'\x08') echo "DELETE" ;; $'\x1b') - # Check if this is an escape sequence (arrow keys) or ESC key - if IFS= read -r -s -n 1 -t 0.1 rest 2> /dev/null; then + if IFS= read -r -s -n 1 -t 1 rest 2> /dev/null; then if [[ "$rest" == "[" ]]; then - if IFS= read -r -s -n 1 -t 0.1 rest2 2> /dev/null; then + if IFS= read -r -s -n 1 -t 1 rest2 2> /dev/null; then case "$rest2" in "A") echo "UP" ;; "B") echo "DOWN" ;; "C") echo "RIGHT" ;; "D") echo "LEFT" ;; "3") - IFS= read -r -s -n 1 -t 0.1 rest3 2> /dev/null + IFS= read -r -s -n 1 -t 1 rest3 2> /dev/null [[ "$rest3" == "~" ]] && echo "DELETE" || echo "OTHER" ;; *) echo "OTHER" ;; esac - else echo "QUIT"; fi + else + echo "QUIT" + fi elif [[ "$rest" == "O" ]]; then - if IFS= read -r -s -n 1 -t 0.1 rest2 2> /dev/null; then + if IFS= read -r -s -n 1 -t 1 rest2 2> /dev/null; then case "$rest2" in "A") echo "UP" ;; "B") echo "DOWN" ;; @@ -198,11 +199,9 @@ read_key() { esac else echo "OTHER"; fi else - # Not an escape sequence, it's ESC key echo "QUIT" fi else - # No following characters, it's ESC key echo "QUIT" fi ;; diff --git a/lib/ui/app_selector.sh b/lib/ui/app_selector.sh index 5c5238a..285ef04 100755 --- a/lib/ui/app_selector.sh +++ b/lib/ui/app_selector.sh @@ -111,15 +111,13 @@ select_apps_for_uninstall() { [[ $max_name_width -gt 60 ]] && max_name_width=60 local -a menu_options=() - # Prepare metadata (comma-separated) for sorting/filtering inside the menu local epochs_csv="" local sizekb_csv="" + local -a names_arr=() local idx=0 for app_data in "${apps_data[@]}"; do - # 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" "$terminal_width" "$max_name_width")") - # Build csv lists (avoid trailing commas) if [[ $idx -eq 0 ]]; then epochs_csv="${epoch:-0}" sizekb_csv="${size_kb:-0}" @@ -127,8 +125,12 @@ select_apps_for_uninstall() { epochs_csv+=",${epoch:-0}" sizekb_csv+=",${size_kb:-0}" fi + names_arr+=("$display_name") ((idx++)) done + # Use newline separator for names (safe for names with commas) + local names_newline + names_newline=$(printf '%s\n' "${names_arr[@]}") # Clear loading message if [[ $app_count -gt 100 ]]; then @@ -143,8 +145,7 @@ select_apps_for_uninstall() { # 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}" + export MOLE_MENU_FILTER_NAMES="$names_newline" # Use paginated menu - result will be stored in MOLE_SELECTION_RESULT # Note: paginated_multi_select enters alternate screen and handles clearing diff --git a/lib/ui/menu_paginated.sh b/lib/ui/menu_paginated.sh index 18c1503..05316cd 100755 --- a/lib/ui/menu_paginated.sh +++ b/lib/ui/menu_paginated.sh @@ -89,13 +89,17 @@ paginated_multi_select() { local top_index=0 local sort_mode="${MOLE_MENU_SORT_MODE:-${MOLE_MENU_SORT_DEFAULT:-date}}" # date|name|size local sort_reverse="${MOLE_MENU_SORT_REVERSE:-false}" + local filter_text="" # Filter keyword # Metadata (optional) # epochs[i] -> last_used_epoch (numeric) for item i # sizekb[i] -> size in KB (numeric) for item i + # filter_names[i] -> name for filtering (if not set, use items[i]) local -a epochs=() local -a sizekb=() + local -a filter_names=() local has_metadata="false" + local has_filter_names="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" @@ -104,6 +108,10 @@ paginated_multi_select() { while IFS= read -r v; do sizekb+=("${v:-0}"); done < <(_pm_parse_csv_to_array "$MOLE_MENU_META_SIZEKB") has_metadata="true" fi + if [[ -n "${MOLE_MENU_FILTER_NAMES:-}" ]]; then + while IFS= read -r v; do filter_names+=("$v"); done <<< "$MOLE_MENU_FILTER_NAMES" + has_filter_names="true" + fi # If no metadata, force name sorting and disable sorting controls if [[ "$has_metadata" == "false" && "$sort_mode" != "name" ]]; then @@ -232,13 +240,33 @@ paginated_multi_select() { printf "%s%s\n" "$clear_line" "$line" >&2 } - # Rebuild the view_indices applying sort + # Rebuild the view_indices applying filter and sort rebuild_view() { - # Sort (skip if no metadata) + local -a active_indices=() + if [[ -n "$filter_text" ]]; then + local filter_lower + filter_lower=$(printf "%s" "$filter_text" | LC_ALL=C tr '[:upper:]' '[:lower:]') + for id in "${orig_indices[@]}"; do + local filter_target + if [[ $has_filter_names == true && -n "${filter_names[id]:-}" ]]; then + filter_target="${filter_names[id]}" + else + filter_target="${items[id]}" + fi + local target_lower + target_lower=$(printf "%s" "$filter_target" | LC_ALL=C tr '[:upper:]' '[:lower:]') + if [[ "$target_lower" == *"$filter_lower"* ]]; then + active_indices+=("$id") + fi + done + else + active_indices=("${orig_indices[@]}") + fi + + # Sort filtered results if [[ "$has_metadata" == "false" ]]; then - # No metadata: just use original indices - view_indices=("${orig_indices[@]}") - elif [[ ${#orig_indices[@]} -eq 0 ]]; then + view_indices=("${active_indices[@]}") + elif [[ ${#active_indices[@]} -eq 0 ]]; then view_indices=() else # Build sort key @@ -262,7 +290,7 @@ paginated_multi_select() { tmpfile=$(mktemp 2> /dev/null) || tmpfile="" if [[ -n "$tmpfile" ]]; then local k id - for id in "${orig_indices[@]}"; do + for id in "${active_indices[@]}"; do case "$sort_mode" in date) k="${epochs[id]:-0}" ;; size) k="${sizekb[id]:-0}" ;; @@ -280,7 +308,7 @@ paginated_multi_select() { rm -f "$tmpfile" else # Fallback: no sorting - view_indices=("${orig_indices[@]}") + view_indices=("${active_indices[@]}") fi fi @@ -321,19 +349,42 @@ paginated_multi_select() { fi } + draw_header() { + printf "\033[1;1H" >&2 + if [[ -n "$filter_text" ]]; then + printf "\r\033[2K${PURPLE_BOLD}%s${NC} ${YELLOW}/ Filter: ${filter_text}_${NC} ${GRAY}(%d/%d)${NC}\n" "${title}" "${#view_indices[@]}" "$total_items" >&2 + elif [[ -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + printf "\r\033[2K${PURPLE_BOLD}%s${NC} ${YELLOW}/ Filter: _ ${NC}${GRAY}(type to search)${NC}\n" "${title}" >&2 + else + printf "\r\033[2K${PURPLE_BOLD}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2 + fi + } + + # Handle filter character input (reduces code duplication) + # Returns 0 if character was handled, 1 if not in filter mode + handle_filter_char() { + local char="$1" + if [[ -z "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + return 1 + fi + if [[ "$char" =~ ^[[:print:]]$ ]]; then + filter_text+="$char" + rebuild_view + cursor_pos=0 + top_index=0 + need_full_redraw=true + fi + return 0 + } + # Draw the complete menu draw_menu() { - # Recalculate items_per_page dynamically to handle window resize items_per_page=$(_pm_calculate_items_per_page) + local clear_line=$'\r\033[2K' printf "\033[H" >&2 - local clear_line="\r\033[2K" - # Use cached selection count (maintained incrementally on toggle) - # No need to loop through all items anymore! - - # Header only - printf "${clear_line}${PURPLE_BOLD}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2 + draw_header # Visible slice local visible_total=${#view_indices[@]} @@ -410,15 +461,19 @@ paginated_multi_select() { local refresh="${GRAY}R Refresh${NC}" local sort_ctrl="${GRAY}S ${sort_status}${NC}" local order_ctrl="${GRAY}O ${reverse_arrow}${NC}" + local filter_ctrl="${GRAY}/ Filter${NC}" - if [[ "$has_metadata" == "true" ]]; then + if [[ -n "$filter_text" ]]; then + local -a _segs_filter=("${GRAY}Backspace${NC}" "${GRAY}ESC Clear${NC}") + _print_wrapped_controls "$sep" "${_segs_filter[@]}" + elif [[ "$has_metadata" == "true" ]]; then # With metadata: show sort controls local term_width="${COLUMNS:-}" [[ -z "$term_width" ]] && term_width=$(tput cols 2> /dev/null || echo 80) [[ "$term_width" =~ ^[0-9]+$ ]] || term_width=80 # Full controls - local -a _segs=("$nav" "$space_select" "$enter" "$refresh" "$sort_ctrl" "$order_ctrl" "$exit") + local -a _segs=("$nav" "$space_select" "$enter" "$refresh" "$sort_ctrl" "$order_ctrl" "$filter_ctrl" "$exit") # Calculate width local total_len=0 seg_count=${#_segs[@]} @@ -429,7 +484,7 @@ paginated_multi_select() { # Level 1: Remove "Space Select" if too wide if [[ $total_len -gt $term_width ]]; then - _segs=("$nav" "$enter" "$refresh" "$sort_ctrl" "$order_ctrl" "$exit") + _segs=("$nav" "$enter" "$refresh" "$sort_ctrl" "$order_ctrl" "$filter_ctrl" "$exit") total_len=0 seg_count=${#_segs[@]} @@ -440,14 +495,14 @@ paginated_multi_select() { # Level 2: Remove sort label if still too wide if [[ $total_len -gt $term_width ]]; then - _segs=("$nav" "$enter" "$refresh" "$order_ctrl" "$exit") + _segs=("$nav" "$enter" "$refresh" "$order_ctrl" "$filter_ctrl" "$exit") fi fi _print_wrapped_controls "$sep" "${_segs[@]}" else # Without metadata: basic controls - local -a _segs_simple=("$nav" "$space_select" "$enter" "$refresh" "$exit") + local -a _segs_simple=("$nav" "$space_select" "$enter" "$refresh" "$filter_ctrl" "$exit") _print_wrapped_controls "$sep" "${_segs_simple[@]}" fi printf "${clear_line}" >&2 @@ -473,52 +528,62 @@ paginated_multi_select() { case "$key" in "QUIT") - cleanup - return 1 + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + filter_text="" + unset MOLE_READ_KEY_FORCE_CHAR + rebuild_view + cursor_pos=0 + top_index=0 + need_full_redraw=true + else + cleanup + return 1 + fi ;; "UP") if [[ ${#view_indices[@]} -eq 0 ]]; then : elif [[ $cursor_pos -gt 0 ]]; then - # Simple cursor move - only redraw affected rows local old_cursor=$cursor_pos ((cursor_pos--)) local new_cursor=$cursor_pos - # Calculate terminal row positions (+3: row 1=header, row 2=blank, row 3=first item) + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + draw_header + fi + local old_row=$((old_cursor + 3)) local new_row=$((new_cursor + 3)) - # Quick redraw: update only the two affected rows printf "\033[%d;1H" "$old_row" >&2 render_item "$old_cursor" false printf "\033[%d;1H" "$new_row" >&2 render_item "$new_cursor" true - # CRITICAL: Move cursor to footer to avoid visual artifacts printf "\033[%d;1H" "$((items_per_page + 4))" >&2 prev_cursor_pos=$cursor_pos - continue # Skip full redraw + continue elif [[ $top_index -gt 0 ]]; then - # Scroll up - redraw visible items only ((top_index--)) - # Redraw all visible items (faster than full screen redraw) + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + draw_header + fi + local start_idx=$top_index local end_idx=$((top_index + items_per_page - 1)) local visible_total=${#view_indices[@]} [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) for ((i = start_idx; i <= end_idx; i++)); do - local row=$((i - start_idx + 3)) # +3 for header + local row=$((i - start_idx + 3)) printf "\033[%d;1H" "$row" >&2 local is_current=false [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true render_item $((i - start_idx)) $is_current done - # Move cursor to footer printf "\033[%d;1H" "$((items_per_page + 4))" >&2 prev_cursor_pos=$cursor_pos @@ -537,28 +602,27 @@ paginated_multi_select() { [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then - # Simple cursor move - only redraw affected rows local old_cursor=$cursor_pos ((cursor_pos++)) local new_cursor=$cursor_pos - # Calculate terminal row positions (+3: row 1=header, row 2=blank, row 3=first item) + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + draw_header + fi + local old_row=$((old_cursor + 3)) local new_row=$((new_cursor + 3)) - # Quick redraw: update only the two affected rows printf "\033[%d;1H" "$old_row" >&2 render_item "$old_cursor" false printf "\033[%d;1H" "$new_row" >&2 render_item "$new_cursor" true - # CRITICAL: Move cursor to footer to avoid visual artifacts printf "\033[%d;1H" "$((items_per_page + 4))" >&2 prev_cursor_pos=$cursor_pos - continue # Skip full redraw + continue elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then - # Scroll down - redraw visible items only ((top_index++)) visible_count=$((${#view_indices[@]} - top_index)) [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page @@ -566,21 +630,23 @@ paginated_multi_select() { cursor_pos=$((visible_count - 1)) fi - # Redraw all visible items (faster than full screen redraw) + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + draw_header + fi + local start_idx=$top_index local end_idx=$((top_index + items_per_page - 1)) local visible_total=${#view_indices[@]} [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) for ((i = start_idx; i <= end_idx; i++)); do - local row=$((i - start_idx + 3)) # +3 for header + local row=$((i - start_idx + 3)) printf "\033[%d;1H" "$row" >&2 local is_current=false [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true render_item $((i - start_idx)) $is_current done - # Move cursor to footer printf "\033[%d;1H" "$((items_per_page + 4))" >&2 prev_cursor_pos=$cursor_pos @@ -630,8 +696,9 @@ paginated_multi_select() { fi ;; "CHAR:s" | "CHAR:S") - if [[ "$has_metadata" == "true" ]]; then - # Cycle sort mode (only if metadata available) + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ "$has_metadata" == "true" ]]; then case "$sort_mode" in date) sort_mode="name" ;; name) sort_mode="size" ;; @@ -642,8 +709,9 @@ paginated_multi_select() { fi ;; "CHAR:j") - # Down navigation (vim style) - if [[ ${#view_indices[@]} -gt 0 ]]; then + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ ${#view_indices[@]} -gt 0 ]]; then local absolute_index=$((top_index + cursor_pos)) local last_index=$((${#view_indices[@]} - 1)) if [[ $absolute_index -lt $last_index ]]; then @@ -659,8 +727,9 @@ paginated_multi_select() { fi ;; "CHAR:k") - # Up navigation (vim style) - if [[ ${#view_indices[@]} -gt 0 ]]; then + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ ${#view_indices[@]} -gt 0 ]]; then if [[ $cursor_pos -gt 0 ]]; then ((cursor_pos--)) need_full_redraw=true @@ -671,13 +740,17 @@ paginated_multi_select() { fi ;; "CHAR:r" | "CHAR:R") - # Trigger Refresh signal - cleanup - return 10 + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + else + cleanup + return 10 + fi ;; "CHAR:o" | "CHAR:O") - if [[ "$has_metadata" == "true" ]]; then - # O toggles reverse order + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ "$has_metadata" == "true" ]]; then if [[ "$sort_reverse" == "true" ]]; then sort_reverse="false" else @@ -687,6 +760,25 @@ paginated_multi_select() { need_full_redraw=true fi ;; + "CHAR:/" | "CHAR:?") + export MOLE_READ_KEY_FORCE_CHAR=1 + need_full_redraw=true + ;; + "DELETE") + if [[ -n "$filter_text" ]]; then + filter_text="${filter_text%?}" + if [[ -z "$filter_text" ]]; then + unset MOLE_READ_KEY_FORCE_CHAR + fi + rebuild_view + cursor_pos=0 + top_index=0 + need_full_redraw=true + fi + ;; + "CHAR:"*) + handle_filter_char "${key#CHAR:}" || true + ;; "ENTER") # Smart Enter behavior # 1. Check if any items are already selected From f965ca25c34faaf5a168a43979a22a34b212d19d Mon Sep 17 00:00:00 2001 From: tw93 Date: Mon, 2 Feb 2026 17:05:42 +0800 Subject: [PATCH 20/72] uninstall: harden cache and removal flow --- bin/uninstall.sh | 35 ++++++++-- lib/uninstall/batch.sh | 153 +++++++++++++++++++++++++++++++---------- 2 files changed, 143 insertions(+), 45 deletions(-) diff --git a/bin/uninstall.sh b/bin/uninstall.sh index cc415fc..437f66d 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -39,11 +39,12 @@ scan_applications() { if [[ $force_rescan == false && -f "$cache_file" ]]; then local cache_age=$(($(get_epoch_seconds) - $(get_file_mtime "$cache_file"))) - [[ $cache_age -eq $(get_epoch_seconds) ]] && cache_age=86401 # Handle mtime read failure + [[ $cache_age -eq $(get_epoch_seconds) ]] && cache_age=86401 + if [[ $cache_age -lt $cache_ttl ]]; then if [[ -t 2 ]]; then echo -e "${GREEN}Loading from cache...${NC}" >&2 - sleep 0.3 # Brief pause so user sees the message + sleep 0.3 fi echo "$cache_file" return 0 @@ -122,7 +123,24 @@ scan_applications() { continue fi - # Bundle ID from plist (fast path). + if [[ -L "$app_path" ]]; then + local link_target + link_target=$(readlink "$app_path" 2> /dev/null) + if [[ -n "$link_target" ]]; then + local resolved_target="$link_target" + if [[ "$link_target" != /* ]]; then + local link_dir + link_dir=$(dirname "$app_path") + resolved_target=$(cd "$link_dir" 2> /dev/null && cd "$(dirname "$link_target")" 2> /dev/null && pwd)/$(basename "$link_target") 2> /dev/null || echo "" + fi + case "$resolved_target" in + /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/etc/*) + continue + ;; + esac + fi + fi + local bundle_id="unknown" if [[ -f "$app_path/Contents/Info.plist" ]]; then bundle_id=$(defaults read "$app_path/Contents/Info.plist" CFBundleIdentifier 2> /dev/null || echo "unknown") @@ -459,7 +477,7 @@ main() { unset MOLE_ALT_SCREEN_ACTIVE unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN fi - rm -f "$apps_file" + [[ "$apps_file" != "$cache_file" ]] && rm -f "$apps_file" return 1 fi @@ -477,7 +495,8 @@ main() { show_cursor clear_screen printf '\033[2J\033[H' >&2 - rm -f "$apps_file" + # Only delete temp files, never the permanent cache + [[ "$apps_file" != "$cache_file" ]] && rm -f "$apps_file" if [[ $exit_code -eq 10 ]]; then force_rescan=true @@ -499,7 +518,7 @@ main() { local selection_count=${#selected_apps[@]} if [[ $selection_count -eq 0 ]]; then echo "No apps selected" - rm -f "$apps_file" + [[ "$apps_file" != "$cache_file" ]] && rm -f "$apps_file" continue fi echo -e "${BLUE}${ICON_CONFIRM}${NC} Selected ${selection_count} apps:" @@ -519,6 +538,7 @@ main() { done ((max_size_width < 5)) && max_size_width=5 ((max_last_width < 5)) && max_last_width=5 + ((max_name_display_width < 16)) && max_name_display_width=16 local term_width=$(tput cols 2> /dev/null || echo 100) local available_for_name=$((term_width - 17 - max_size_width - max_last_width)) @@ -577,7 +597,8 @@ main() { batch_uninstall_applications - rm -f "$apps_file" + # Only delete temp files, never the permanent cache + [[ "$apps_file" != "$cache_file" ]] && rm -f "$apps_file" echo -e "${GRAY}Press Enter to return to application list, any other key to exit...${NC}" local key diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index 5f11973..cba0ee8 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -156,18 +156,8 @@ remove_file_list() { continue fi - # Symlinks are handled separately using rm (not safe_remove/safe_sudo_remove) - # because safe_sudo_remove() refuses symlinks entirely as a TOCTOU protection. - # This is safe because: - # 1. The path has already passed validate_path_for_deletion() above - # 2. rm on a symlink only removes the link itself, NOT the target - # 3. The symlink deletion is logged via operations.log if [[ -L "$file" ]]; then - if [[ "$use_sudo" == "true" ]]; then - sudo rm "$file" 2> /dev/null && ((++count)) || true - else - rm "$file" 2> /dev/null && ((++count)) || true - fi + safe_remove_symlink "$file" "$use_sudo" && ((++count)) || true else if [[ "$use_sudo" == "true" ]]; then safe_sudo_remove "$file" && ((++count)) || true @@ -194,7 +184,16 @@ batch_uninstall_applications() { old_trap_int=$(trap -p INT) old_trap_term=$(trap -p TERM) + _cleanup_sudo_keepalive() { + if [[ -n "${sudo_keepalive_pid:-}" ]]; then + kill "$sudo_keepalive_pid" 2> /dev/null || true + wait "$sudo_keepalive_pid" 2> /dev/null || true + sudo_keepalive_pid="" + fi + } + _restore_uninstall_traps() { + _cleanup_sudo_keepalive if [[ -n "$old_trap_int" ]]; then eval "$old_trap_int" else @@ -207,8 +206,8 @@ batch_uninstall_applications() { fi } - # Trap to clean up spinner and uninstall mode on interrupt - trap 'stop_inline_spinner 2>/dev/null; unset MOLE_UNINSTALL_MODE; echo ""; _restore_uninstall_traps; return 130' INT TERM + # Trap to clean up spinner, sudo keepalive, and uninstall mode on interrupt + trap 'stop_inline_spinner 2>/dev/null; _cleanup_sudo_keepalive; unset MOLE_UNINSTALL_MODE; echo ""; _restore_uninstall_traps; return 130' INT TERM # Pre-scan: running apps, sudo needs, size. local -a running_apps=() @@ -260,16 +259,16 @@ batch_uninstall_applications() { fi # Size estimate includes related and system files. - local app_size_kb=$(get_path_size_kb "$app_path") - local related_files=$(find_app_files "$bundle_id" "$app_name") - local related_size_kb=$(calculate_total_size "$related_files") + local app_size_kb=$(get_path_size_kb "$app_path" || echo "0") + local related_files=$(find_app_files "$bundle_id" "$app_name" || true) + local related_size_kb=$(calculate_total_size "$related_files" || echo "0") # system_files is a newline-separated string, not an array. # shellcheck disable=SC2178,SC2128 - local system_files=$(find_app_system_files "$bundle_id" "$app_name") + local system_files=$(find_app_system_files "$bundle_id" "$app_name" || true) # shellcheck disable=SC2128 - local system_size_kb=$(calculate_total_size "$system_files") + local system_size_kb=$(calculate_total_size "$system_files" || echo "0") local total_kb=$((app_size_kb + related_size_kb + system_size_kb)) - ((total_estimated_size += total_kb)) + ((total_estimated_size += total_kb)) || true # shellcheck disable=SC2128 if [[ -n "$system_files" ]]; then @@ -282,15 +281,15 @@ batch_uninstall_applications() { # Check for sensitive user data once. local has_sensitive_data="false" - if has_sensitive_data "$related_files"; then + if has_sensitive_data "$related_files" 2> /dev/null; then has_sensitive_data="true" fi # Store details for later use (base64 keeps lists on one line). local encoded_files - encoded_files=$(printf '%s' "$related_files" | base64 | tr -d '\n') + encoded_files=$(printf '%s' "$related_files" | base64 | tr -d '\n' || echo "") local encoded_system_files - encoded_system_files=$(printf '%s' "$system_files" | base64 | tr -d '\n') + encoded_system_files=$(printf '%s' "$system_files" | base64 | tr -d '\n' || echo "") app_details+=("$app_name|$app_path|$bundle_id|$total_kb|$encoded_files|$encoded_system_files|$has_sensitive_data|$needs_sudo|$is_brew_cask|$cask_name") done if [[ -t 1 ]]; then stop_inline_spinner; fi @@ -480,12 +479,38 @@ batch_uninstall_applications() { fi fi elif [[ "$needs_sudo" == true ]]; then - if ! safe_sudo_remove "$app_path"; then - local app_owner=$(get_file_owner "$app_path") - if [[ -n "$app_owner" && "$app_owner" != "$current_user" && "$app_owner" != "root" ]]; then - reason="owned by $app_owner, try 'sudo chown $(whoami) \"$app_path\"'" + if [[ -L "$app_path" ]]; then + local link_target + link_target=$(readlink "$app_path" 2> /dev/null) + if [[ -n "$link_target" ]]; then + local resolved_target="$link_target" + if [[ "$link_target" != /* ]]; then + local link_dir + link_dir=$(dirname "$app_path") + resolved_target=$(cd "$link_dir" 2> /dev/null && cd "$(dirname "$link_target")" 2> /dev/null && pwd)/$(basename "$link_target") 2> /dev/null || echo "" + fi + case "$resolved_target" in + /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/etc/*) + reason="protected system symlink, cannot remove" + ;; + *) + if ! safe_remove_symlink "$app_path" "true"; then + reason="failed to remove symlink" + fi + ;; + esac else - reason="permission denied, try 'mole touchid' for passwordless sudo" + if ! safe_remove_symlink "$app_path" "true"; then + reason="failed to remove symlink" + fi + fi + else + local ret=0 + safe_sudo_remove "$app_path" || ret=$? + if [[ $ret -ne 0 ]]; then + local diagnosis + diagnosis=$(diagnose_removal_failure "$ret" "$app_name") + IFS='|' read -r reason suggestion <<< "$diagnosis" fi fi else @@ -544,17 +569,19 @@ batch_uninstall_applications() { ((total_items++)) success_items+=("$app_name") else - # Show failure if [[ -t 1 ]]; then if [[ ${#app_details[@]} -gt 1 ]]; then echo -e "${ICON_ERROR} [$current_index/${#app_details[@]}] ${app_name} ${GRAY}, $reason${NC}" else echo -e "${ICON_ERROR} ${app_name} failed: $reason" fi + if [[ -n "${suggestion:-}" ]]; then + echo -e "${GRAY} → ${suggestion}${NC}" + fi fi ((failed_count++)) - failed_items+=("$app_name:$reason") + failed_items+=("$app_name:$reason:${suggestion:-}") fi done @@ -617,8 +644,20 @@ batch_uninstall_applications() { local failed_list="${failed_names[*]}" local reason_summary="could not be removed" + local suggestion_text="" if [[ $failed_count -eq 1 ]]; then - local first_reason=${failed_items[0]#*:} + # Extract reason and suggestion from format: app:reason:suggestion + local item="${failed_items[0]}" + local without_app="${item#*:}" + local first_reason="${without_app%%:*}" + local first_suggestion="${without_app#*:}" + + # If suggestion is same as reason, there was no suggestion part + # Also check if suggestion is empty + if [[ "$first_suggestion" != "$first_reason" && -n "$first_suggestion" ]]; then + suggestion_text="${GRAY} → ${first_suggestion}${NC}" + fi + case "$first_reason" in still*running*) reason_summary="is still running" ;; remove*failed*) reason_summary="could not be removed" ;; @@ -628,6 +667,9 @@ batch_uninstall_applications() { esac fi summary_details+=("Failed: ${RED}${failed_list}${NC} ${reason_summary}") + if [[ -n "$suggestion_text" ]]; then + summary_details+=("$suggestion_text") + fi fi if [[ $success_count -eq 0 && $failed_count -eq 0 ]]; then @@ -683,20 +725,55 @@ batch_uninstall_applications() { fi fi - # Clean up sudo keepalive if it was started. - if [[ -n "${sudo_keepalive_pid:-}" ]]; then - kill "$sudo_keepalive_pid" 2> /dev/null || true - wait "$sudo_keepalive_pid" 2> /dev/null || true - sudo_keepalive_pid="" - fi + _cleanup_sudo_keepalive # Disable uninstall mode unset MOLE_UNINSTALL_MODE - # Invalidate cache if any apps were successfully uninstalled. if [[ $success_count -gt 0 ]]; then local cache_file="$HOME/.cache/mole/app_scan_cache" - rm -f "$cache_file" 2> /dev/null || true + if [[ -f "$cache_file" ]]; then + local -a removed_paths=() + for detail in "${app_details[@]}"; do + IFS='|' read -r app_name app_path _ _ _ _ <<< "$detail" + for success_name in "${success_items[@]}"; do + if [[ "$success_name" == "$app_name" ]]; then + removed_paths+=("$app_path") + break + fi + done + done + + if [[ ${#removed_paths[@]} -gt 0 ]]; then + local temp_cache + temp_cache=$(create_temp_file) + local line_removed=false + while IFS='|' read -r epoch path rest; do + local keep_line=true + for removed_path in "${removed_paths[@]}"; do + if [[ "$path" == "$removed_path" ]]; then + keep_line=false + line_removed=true + break + fi + done + if [[ $keep_line == true && -n "$path" ]]; then + echo "${epoch}|${path}|${rest}" + fi + done < "$cache_file" > "$temp_cache" + + if [[ $line_removed == true ]]; then + if [[ -s "$temp_cache" ]]; then + mv "$temp_cache" "$cache_file" 2> /dev/null || rm -f "$temp_cache" + else + # All apps removed, delete cache to force rescan + rm -f "$cache_file" "$temp_cache" + fi + else + rm -f "$temp_cache" + fi + fi + fi fi _restore_uninstall_traps From 05faf2b69168a2604ac15ad6ef1589c80d58eaea Mon Sep 17 00:00:00 2001 From: tw93 Date: Mon, 2 Feb 2026 17:06:00 +0800 Subject: [PATCH 21/72] core: improve file removal diagnostics --- lib/core/file_ops.sh | 154 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 134 insertions(+), 20 deletions(-) diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index 920c45b..7d15fdd 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -10,6 +10,11 @@ if [[ -n "${MOLE_FILE_OPS_LOADED:-}" ]]; then fi readonly MOLE_FILE_OPS_LOADED=1 +# Error codes for removal operations +readonly MOLE_ERR_SIP_PROTECTED=10 +readonly MOLE_ERR_AUTH_FAILED=11 +readonly MOLE_ERR_READONLY_FS=12 + # Ensure dependencies are loaded _MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" if [[ -z "${MOLE_BASE_LOADED:-}" ]]; then @@ -25,6 +30,35 @@ if [[ -z "${MOLE_TIMEOUT_LOADED:-}" ]]; then source "$_MOLE_CORE_DIR/timeout.sh" fi +# ============================================================================ +# Utility Functions +# ============================================================================ + +# Format duration in seconds to human readable string (e.g., "5 days", "2 months") +format_duration_human() { + local seconds="${1:-0}" + [[ ! "$seconds" =~ ^[0-9]+$ ]] && seconds=0 + + local days=$((seconds / 86400)) + + if [[ $days -eq 0 ]]; then + echo "today" + elif [[ $days -eq 1 ]]; then + echo "1 day" + elif [[ $days -lt 7 ]]; then + echo "${days} days" + elif [[ $days -lt 30 ]]; then + local weeks=$((days / 7)) + [[ $weeks -eq 1 ]] && echo "1 week" || echo "${weeks} weeks" + elif [[ $days -lt 365 ]]; then + local months=$((days / 30)) + [[ $months -eq 1 ]] && echo "1 month" || echo "${months} months" + else + local years=$((days / 365)) + [[ $years -eq 1 ]] && echo "1 year" || echo "${years} years" + fi +} + # ============================================================================ # Path Validation # ============================================================================ @@ -235,28 +269,54 @@ safe_remove() { fi } +# Safe symlink removal (for pre-validated symlinks only) +safe_remove_symlink() { + local path="$1" + local use_sudo="${2:-false}" + + if [[ ! -L "$path" ]]; then + return 1 + fi + + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + debug_log "[DRY RUN] Would remove symlink: $path" + return 0 + fi + + local rm_exit=0 + if [[ "$use_sudo" == "true" ]]; then + sudo rm "$path" 2> /dev/null || rm_exit=$? + else + rm "$path" 2> /dev/null || rm_exit=$? + fi + + if [[ $rm_exit -eq 0 ]]; then + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "REMOVED" "$path" "symlink" + return 0 + else + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "symlink removal failed" + return 1 + fi +} + # Safe sudo removal with symlink protection safe_sudo_remove() { local path="$1" - # Validate path if ! validate_path_for_deletion "$path"; then log_error "Path validation failed for sudo remove: $path" return 1 fi - # Check if path exists if [[ ! -e "$path" ]]; then return 0 fi - # Additional check: reject symlinks for sudo operations if [[ -L "$path" ]]; then log_error "Refusing to sudo remove symlink: $path" return 1 fi - # Dry-run mode: log but don't delete if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then if [[ "${MO_DEBUG:-}" == "1" ]]; then local file_type="file" @@ -278,21 +338,21 @@ safe_sudo_remove() { local now now=$(date +%s 2> /dev/null || echo "0") if [[ "$mod_time" -gt 0 && "$now" -gt 0 ]]; then - file_age=$(((now - mod_time) / 86400)) + local age_seconds=$((now - mod_time)) + file_age=$(format_duration_human "$age_seconds") fi fi fi - debug_file_action "[DRY RUN] Would remove, sudo" "$path" "$file_size" "$file_age" + log_info "[DRY-RUN] Would sudo remove: $file_type $path" + [[ -n "$file_size" ]] && log_info " Size: $file_size" + [[ -n "$file_age" ]] && log_info " Age: $file_age" else - debug_log "[DRY RUN] Would remove, sudo: $path" + log_info "[DRY-RUN] Would sudo remove: $path" fi return 0 fi - debug_log "Removing, sudo: $path" - - # Calculate size before deletion for logging local size_kb=0 local size_human="" if oplog_enabled; then @@ -304,15 +364,35 @@ safe_sudo_remove() { fi fi - # Perform the deletion - if sudo rm -rf "$path" 2> /dev/null; then # SAFE: safe_sudo_remove implementation + local output + local ret + output=$(sudo rm -rf "$path" 2>&1) + ret=$? + + if [[ $ret -eq 0 ]]; then log_operation "${MOLE_CURRENT_COMMAND:-clean}" "REMOVED" "$path" "$size_human" return 0 - else - log_error "Failed to remove, sudo: $path" - log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "sudo error" - return 1 fi + + case "$output" in + *"Operation not permitted"*) + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "sip/mdm protected" + return "$MOLE_ERR_SIP_PROTECTED" + ;; + *"Read-only file system"*) + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "readonly filesystem" + return "$MOLE_ERR_READONLY_FS" + ;; + *"Sorry, try again"* | *"incorrect password"*) + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "auth failed" + return "$MOLE_ERR_AUTH_FAILED" + ;; + *) + log_error "Failed to remove, sudo: $path" + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "sudo error" + return 1 + ;; + esac } # ============================================================================ @@ -414,16 +494,13 @@ get_path_size_kb() { echo "0" return } - # Direct execution without timeout overhead - critical for performance in loops - # Use || echo 0 to ensure failure in du (e.g. permission error) doesn't exit script under set -e - # Pipefail would normally cause the pipeline to fail if du fails, but || handle catches it. local size size=$(command du -skP "$path" 2> /dev/null | awk 'NR==1 {print $1; exit}' || true) - # Ensure size is a valid number (fix for non-numeric du output) if [[ "$size" =~ ^[0-9]+$ ]]; then echo "$size" else + [[ "${MO_DEBUG:-}" == "1" ]] && debug_log "get_path_size_kb: Failed to get size for $path (returned: $size)" echo "0" fi } @@ -443,3 +520,40 @@ calculate_total_size() { echo "$total_kb" } + +diagnose_removal_failure() { + local exit_code="$1" + local app_name="${2:-application}" + + local reason="" + local suggestion="" + local touchid_file="/etc/pam.d/sudo" + + case "$exit_code" in + "$MOLE_ERR_SIP_PROTECTED") + reason="protected by macOS (SIP/MDM)" + ;; + "$MOLE_ERR_AUTH_FAILED") + reason="authentication failed" + if [[ -f "$touchid_file" ]] && grep -q "pam_tid.so" "$touchid_file" 2> /dev/null; then + suggestion="Check your password or restart Terminal" + else + suggestion="Try 'mole touchid' to enable fingerprint auth" + fi + ;; + "$MOLE_ERR_READONLY_FS") + reason="filesystem is read-only" + suggestion="Check if disk needs repair" + ;; + *) + reason="permission denied" + if [[ -f "$touchid_file" ]] && grep -q "pam_tid.so" "$touchid_file" 2> /dev/null; then + suggestion="Try running again or check file ownership" + else + suggestion="Try 'mole touchid' or check with 'ls -l'" + fi + ;; + esac + + echo "$reason|$suggestion" +} From 34bbf82b9622e34737d62535e076223dc3edce85 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 2 Feb 2026 09:06:34 +0000 Subject: [PATCH 22/72] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 872393c..23ea4e6 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -46,17 +46,6 @@ - - - - - - - - alexandear - - - @@ -67,6 +56,17 @@ iamxorum + + + + + + + + + alexandear + + From 48db04a95bccbd9125588ac083529cc04d18b6a4 Mon Sep 17 00:00:00 2001 From: tw93 Date: Mon, 2 Feb 2026 17:15:32 +0800 Subject: [PATCH 23/72] fix: add comment for clarity in safe_sudo_remove function --- lib/core/file_ops.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index 7d15fdd..a4e739b 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -366,7 +366,7 @@ safe_sudo_remove() { local output local ret - output=$(sudo rm -rf "$path" 2>&1) + output=$(sudo rm -rf "$path" 2>&1) # safe_remove ret=$? if [[ $ret -eq 0 ]]; then From 5f88d84d3f81278474c63a8926db3bd243af1f55 Mon Sep 17 00:00:00 2001 From: tw93 Date: Mon, 2 Feb 2026 17:18:32 +0800 Subject: [PATCH 24/72] fix: update authentication failure messages for clarity --- lib/core/file_ops.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index a4e739b..3ad9f76 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -383,7 +383,7 @@ safe_sudo_remove() { log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "readonly filesystem" return "$MOLE_ERR_READONLY_FS" ;; - *"Sorry, try again"* | *"incorrect password"*) + *"Sorry, try again"* | *"incorrect passphrase"* | *"incorrect credentials"*) log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "auth failed" return "$MOLE_ERR_AUTH_FAILED" ;; @@ -536,7 +536,7 @@ diagnose_removal_failure() { "$MOLE_ERR_AUTH_FAILED") reason="authentication failed" if [[ -f "$touchid_file" ]] && grep -q "pam_tid.so" "$touchid_file" 2> /dev/null; then - suggestion="Check your password or restart Terminal" + suggestion="Check your credentials or restart Terminal" else suggestion="Try 'mole touchid' to enable fingerprint auth" fi From d02bb49497d3df89239287f6581b32b57679b482 Mon Sep 17 00:00:00 2001 From: tw93 Date: Mon, 2 Feb 2026 17:28:57 +0800 Subject: [PATCH 25/72] fix: update macOS installer cleanup logic to remove installers older than 14 days --- lib/clean/system.sh | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/lib/clean/system.sh b/lib/clean/system.sh index e3d282e..086c51c 100644 --- a/lib/clean/system.sh +++ b/lib/clean/system.sh @@ -40,7 +40,7 @@ clean_deep_system() { local mtime=$(get_file_mtime "/macOS Install Data") local age_days=$((($(get_epoch_seconds) - mtime) / 86400)) debug_log "Found macOS Install Data, age ${age_days} days" - if [[ $age_days -ge 30 ]]; then + if [[ $age_days -ge 14 ]]; then local size_kb=$(get_path_size_kb "/macOS Install Data") if [[ -n "$size_kb" && "$size_kb" -gt 0 ]]; then local size_human=$(bytes_to_human "$((size_kb * 1024))") @@ -50,9 +50,38 @@ clean_deep_system() { fi fi else - debug_log "Keeping macOS Install Data, only ${age_days} days old, needs 30+" + debug_log "Keeping macOS Install Data, only ${age_days} days old, needs 14+" fi fi + # Clean macOS installer apps (e.g., "Install macOS Sequoia.app") + # Only remove installers older than 14 days and not currently running + local installer_cleaned=0 + for installer_app in /Applications/Install\ macOS*.app; do + [[ -d "$installer_app" ]] || continue + local app_name=$(basename "$installer_app") + # Skip if installer is currently running + if pgrep -f "$installer_app" > /dev/null 2>&1; then + debug_log "Skipping $app_name: currently running" + continue + fi + # Check age (same 14-day threshold as /macOS Install Data) + local mtime=$(get_file_mtime "$installer_app") + local age_days=$((($(get_epoch_seconds) - mtime) / 86400)) + if [[ $age_days -lt 14 ]]; then + debug_log "Keeping $app_name: only ${age_days} days old, needs 14+" + continue + fi + local size_kb=$(get_path_size_kb "$installer_app") + if [[ -n "$size_kb" && "$size_kb" -gt 0 ]]; then + local size_human=$(bytes_to_human "$((size_kb * 1024))") + debug_log "Cleaning macOS installer: $app_name, $size_human, ${age_days} days old" + if safe_sudo_remove "$installer_app"; then + log_success "$app_name, $size_human" + ((installer_cleaned++)) + fi + fi + done + [[ $installer_cleaned -gt 0 ]] && debug_log "Cleaned $installer_cleaned macOS installer(s)" start_section_spinner "Scanning system caches..." local code_sign_cleaned=0 local found_count=0 From c6e58c4ead764217d5dea2eb919023ea16a72f9f Mon Sep 17 00:00:00 2001 From: tw93 Date: Mon, 2 Feb 2026 17:38:29 +0800 Subject: [PATCH 26/72] fix: replace clear with clear_screen for better clarity in main function --- bin/optimize.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/optimize.sh b/bin/optimize.sh index 6dbaaec..835be7b 100755 --- a/bin/optimize.sh +++ b/bin/optimize.sh @@ -392,7 +392,7 @@ main() { trap handle_interrupt INT TERM if [[ -t 1 ]]; then - clear + clear_screen fi print_header From 09ae5ee3eb89261743117f833d65f9439c61aea5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:46:04 +0800 Subject: [PATCH 27/72] chore(deps): bump actions/cache from 5.0.2 to 5.0.3 (#405) Bumps [actions/cache](https://github.com/actions/cache) from 5.0.2 to 5.0.3. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/8b402f58fbc84540c8b491a91e594a4576fec3d7...cdf6c1fa76f9f475f3d7449005a359c84ca0f306) --- updated-dependencies: - dependency-name: actions/cache dependency-version: 5.0.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/check.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 716382a..0ddaeeb 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -21,7 +21,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Cache Homebrew - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v4 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4 with: path: | ~/Library/Caches/Homebrew @@ -74,7 +74,7 @@ jobs: ref: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.head_ref) || github.ref }} - name: Cache Homebrew - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v4 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4 with: path: | ~/Library/Caches/Homebrew From 7133ea4966688af3bfa9f86d57310c40cf2eb78a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:46:14 +0800 Subject: [PATCH 28/72] chore(deps): bump github.com/shirou/gopsutil/v4 from 4.25.12 to 4.26.1 (#406) Bumps [github.com/shirou/gopsutil/v4](https://github.com/shirou/gopsutil) from 4.25.12 to 4.26.1. - [Release notes](https://github.com/shirou/gopsutil/releases) - [Commits](https://github.com/shirou/gopsutil/compare/v4.25.12...v4.26.1) --- updated-dependencies: - dependency-name: github.com/shirou/gopsutil/v4 dependency-version: 4.26.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 8508e51..48a627c 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 - github.com/shirou/gopsutil/v4 v4.25.12 + github.com/shirou/gopsutil/v4 v4.26.1 golang.org/x/sync v0.19.0 ) diff --git a/go.sum b/go.sum index ff620af..baf6360 100644 --- a/go.sum +++ b/go.sum @@ -53,8 +53,8 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/shirou/gopsutil/v4 v4.25.12 h1:e7PvW/0RmJ8p8vPGJH4jvNkOyLmbkXgXW4m6ZPic6CY= -github.com/shirou/gopsutil/v4 v4.25.12/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU= +github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo= +github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= From 4f3eb0eb62c4fda2a30db78d3dfc85f6b4a5ca83 Mon Sep 17 00:00:00 2001 From: Andrei Murariu <83287213+iamxorum@users.noreply.github.com> Date: Tue, 3 Feb 2026 08:47:44 +0200 Subject: [PATCH 29/72] bug-fix: uninstall raycast leftovers (#404) --- lib/core/app_protection.sh | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index a270626..bb93abe 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -1091,6 +1091,32 @@ find_app_files() { [[ "$bundle_id" =~ microsoft.*vscode ]] && [[ -d ~/.vscode ]] && files_to_clean+=("$HOME/.vscode") [[ "$app_name" =~ Docker ]] && [[ -d ~/.docker ]] && files_to_clean+=("$HOME/.docker") + # 7. Raycast + if [[ "$bundle_id" == "com.raycast.macos" ]]; then + local raycast_parents=( + "$HOME/Library/Application Support" + "$HOME/Library/Application Scripts" + "$HOME/Library/Containers" + ) + for parent in "${raycast_parents[@]}"; do + [[ -d "$parent" ]] || continue + while IFS= read -r -d '' p; do + files_to_clean+=("$p") + done < <(command find "$parent" -maxdepth 1 -type d -iname "*raycast*" -print0 2> /dev/null) + done + if [[ -d "$HOME/Library/Caches" ]]; then + while IFS= read -r -d '' p; do + files_to_clean+=("$p") + done < <(command find "$HOME/Library/Caches" -maxdepth 2 -type d -iname "*raycast*" -print0 2> /dev/null) + fi + local code_storage="$HOME/Library/Application Support/Code/User/globalStorage" + if [[ -d "$code_storage" ]]; then + while IFS= read -r -d '' p; do + files_to_clean+=("$p") + done < <(command find "$code_storage" -maxdepth 1 -type d -iname "*raycast*" -print0 2> /dev/null) + fi + fi + # Output results if [[ ${#files_to_clean[@]} -gt 0 ]]; then printf '%s\n' "${files_to_clean[@]}" @@ -1184,6 +1210,13 @@ find_app_system_files() { done < <(command find /private/var/db/receipts -maxdepth 1 \( -name "*$bundle_id*" \) -print0 2> /dev/null) fi + # Raycast system-level (*raycast* under /Library/Application Support) + if [[ "$bundle_id" == "com.raycast.macos" && -d "/Library/Application Support" ]]; then + while IFS= read -r -d '' p; do + system_files+=("$p") + done < <(command find "/Library/Application Support" -maxdepth 1 -type d -iname "*raycast*" -print0 2> /dev/null) + fi + local receipt_files="" receipt_files=$(find_app_receipt_files "$bundle_id") From a5c7abd2276eb9bd376e877b2068a3e4064cdc9b Mon Sep 17 00:00:00 2001 From: tw93 Date: Tue, 3 Feb 2026 14:53:10 +0800 Subject: [PATCH 30/72] refactor: optimize raycast cleanup code structure Improve code readability and maintainability: - Simplify conditional logic with chained operators - Add clarifying comments for different cleanup scopes - Rename variables for better semantic clarity - Maintain consistent style with other app cleanup patterns --- lib/core/app_protection.sh | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index bb93abe..ed930db 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -1093,28 +1093,28 @@ find_app_files() { # 7. Raycast if [[ "$bundle_id" == "com.raycast.macos" ]]; then - local raycast_parents=( + # Standard user directories + local raycast_dirs=( "$HOME/Library/Application Support" "$HOME/Library/Application Scripts" "$HOME/Library/Containers" ) - for parent in "${raycast_parents[@]}"; do - [[ -d "$parent" ]] || continue - while IFS= read -r -d '' p; do + for dir in "${raycast_dirs[@]}"; do + [[ -d "$dir" ]] && while IFS= read -r -d '' p; do files_to_clean+=("$p") - done < <(command find "$parent" -maxdepth 1 -type d -iname "*raycast*" -print0 2> /dev/null) + done < <(command find "$dir" -maxdepth 1 -type d -iname "*raycast*" -print0 2> /dev/null) done - if [[ -d "$HOME/Library/Caches" ]]; then - while IFS= read -r -d '' p; do - files_to_clean+=("$p") - done < <(command find "$HOME/Library/Caches" -maxdepth 2 -type d -iname "*raycast*" -print0 2> /dev/null) - fi - local code_storage="$HOME/Library/Application Support/Code/User/globalStorage" - if [[ -d "$code_storage" ]]; then - while IFS= read -r -d '' p; do - files_to_clean+=("$p") - done < <(command find "$code_storage" -maxdepth 1 -type d -iname "*raycast*" -print0 2> /dev/null) - fi + + # Cache (deeper search) + [[ -d "$HOME/Library/Caches" ]] && while IFS= read -r -d '' p; do + files_to_clean+=("$p") + done < <(command find "$HOME/Library/Caches" -maxdepth 2 -type d -iname "*raycast*" -print0 2> /dev/null) + + # VSCode extension storage + local vscode_global="$HOME/Library/Application Support/Code/User/globalStorage" + [[ -d "$vscode_global" ]] && while IFS= read -r -d '' p; do + files_to_clean+=("$p") + done < <(command find "$vscode_global" -maxdepth 1 -type d -iname "*raycast*" -print0 2> /dev/null) fi # Output results @@ -1210,9 +1210,9 @@ find_app_system_files() { done < <(command find /private/var/db/receipts -maxdepth 1 \( -name "*$bundle_id*" \) -print0 2> /dev/null) fi - # Raycast system-level (*raycast* under /Library/Application Support) - if [[ "$bundle_id" == "com.raycast.macos" && -d "/Library/Application Support" ]]; then - while IFS= read -r -d '' p; do + # Raycast system-level files + if [[ "$bundle_id" == "com.raycast.macos" ]]; then + [[ -d "/Library/Application Support" ]] && while IFS= read -r -d '' p; do system_files+=("$p") done < <(command find "/Library/Application Support" -maxdepth 1 -type d -iname "*raycast*" -print0 2> /dev/null) fi From d3206354f6f5da2025a8d9c72e653fdde65827e0 Mon Sep 17 00:00:00 2001 From: tw93 Date: Tue, 3 Feb 2026 15:09:06 +0800 Subject: [PATCH 31/72] docs: add Raycast setup instructions to README for better user guidance --- README.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e73df6b..ace5934 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,22 @@ curl -fsSL https://raw.githubusercontent.com/tw93/Mole/main/scripts/setup-quick- Adds 5 commands: `clean`, `uninstall`, `optimize`, `analyze`, `status`. -Mole automatically detects your terminal, or set `MO_LAUNCHER_APP=` to override. For Raycast users: if this is your first script directory, add it via Raycast Extensions → Add Script Directory, then run "Reload Script Directories". +### Raycast Setup + +After running the script above, **complete these steps in Raycast**: + +1. Open Raycast Settings (⌘ + ,) +2. Go to **Extensions** → **Script Commands** +3. Click **"Add Script Directory"** (or **"+"**) +4. Add path: `~/Library/Application Support/Raycast/script-commands` +5. Search in Raycast for: **"Reload Script Directories"** and run it +6. Done! Search for `mole`, `clean`, or `optimize` to use the commands + +> **Note**: The script creates the commands automatically, but Raycast requires you to manually add the script directory. This is a one-time setup. + +### Terminal Detection + +Mole automatically detects your terminal app (Warp, Ghostty, Alacritty, Kitty, WezTerm, etc.). To override, set `MO_LAUNCHER_APP=` in your environment. ## Community Love From bad1c7123183e46de58f1af03114774edf2bab33 Mon Sep 17 00:00:00 2001 From: tw93 Date: Tue, 3 Feb 2026 16:37:33 +0800 Subject: [PATCH 32/72] fix: protect Gradle cache from cleanup by default Gradle build cache (~/.gradle/caches) is now protected by default whitelist, similar to Maven repository. This prevents unintentional deletion of large dependency caches that take time and bandwidth to re-download. - Add ~/.gradle/caches/* and ~/.gradle/daemon/* to DEFAULT_WHITELIST_PATTERNS - Remove Gradle cleanup from clean_dev_jvm() function - Users can disable protection via 'mo clean --whitelist' if needed Fixes #408 --- lib/clean/dev.sh | 3 +-- lib/core/base.sh | 2 ++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/clean/dev.sh b/lib/clean/dev.sh index 6f441f1..3306164 100644 --- a/lib/clean/dev.sh +++ b/lib/clean/dev.sh @@ -207,9 +207,8 @@ clean_dev_mobile() { safe_clean ~/.cache/swift-package-manager/* "Swift package manager cache" } # JVM ecosystem caches. +# Gradle excluded (default whitelist, like Maven). Remove via: mo clean --whitelist clean_dev_jvm() { - safe_clean ~/.gradle/caches/* "Gradle caches" - safe_clean ~/.gradle/daemon/* "Gradle daemon logs" safe_clean ~/.sbt/* "SBT cache" safe_clean ~/.ivy2/cache/* "Ivy cache" } diff --git a/lib/core/base.sh b/lib/core/base.sh index e2149fb..d0f813b 100644 --- a/lib/core/base.sh +++ b/lib/core/base.sh @@ -63,6 +63,8 @@ declare -a DEFAULT_WHITELIST_PATTERNS=( "$HOME/Library/Caches/ms-playwright*" "$HOME/.cache/huggingface*" "$HOME/.m2/repository/*" + "$HOME/.gradle/caches/*" + "$HOME/.gradle/daemon/*" "$HOME/.ollama/models/*" "$HOME/Library/Caches/com.nssurge.surge-mac/*" "$HOME/Library/Application Support/com.nssurge.surge-mac/*" From 579c9639405bd0ee23d8c7b6e59ed1d95ef383db Mon Sep 17 00:00:00 2001 From: tw93 Date: Tue, 3 Feb 2026 17:36:15 +0800 Subject: [PATCH 33/72] uninstall: refine protection flow and menu filtering --- lib/core/app_protection.sh | 11 ++++++-- lib/ui/menu_paginated.sh | 8 ++++++ lib/uninstall/batch.sh | 57 ++++++++++++-------------------------- 3 files changed, 34 insertions(+), 42 deletions(-) diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index ed930db..90fa7dd 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -707,7 +707,13 @@ should_protect_data() { ;; esac - # Most apps won't match, return early + # Fallback: check against the full DATA_PROTECTED_BUNDLES list + for pattern in "${DATA_PROTECTED_BUNDLES[@]}"; do + if bundle_matches_pattern "$bundle_id" "$pattern"; then + return 0 + fi + done + return 1 } @@ -772,7 +778,8 @@ should_protect_path() { # Matches: .../Library/Group Containers/group.id/... if [[ "$path" =~ /Library/Containers/([^/]+) ]] || [[ "$path" =~ /Library/Group\ Containers/([^/]+) ]]; then local bundle_id="${BASH_REMATCH[1]}" - if should_protect_data "$bundle_id"; then + # In uninstall mode, only system components are protected; skip data protection + if [[ "${MOLE_UNINSTALL_MODE:-0}" != "1" ]] && should_protect_data "$bundle_id"; then return 0 fi fi diff --git a/lib/ui/menu_paginated.sh b/lib/ui/menu_paginated.sh index 05316cd..57c2478 100755 --- a/lib/ui/menu_paginated.sh +++ b/lib/ui/menu_paginated.sh @@ -657,6 +657,14 @@ paginated_multi_select() { fi ;; "SPACE") + # In filter mode with active text, treat space as search character + if [[ -n "$filter_text" ]]; then + filter_text+=" " + rebuild_view + cursor_pos=0 + need_full_redraw=true + continue + fi local idx=$((top_index + cursor_pos)) if [[ $idx -lt ${#view_indices[@]} ]]; then local real="${view_indices[idx]}" diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index cba0ee8..9d03af9 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -300,18 +300,15 @@ batch_uninstall_applications() { echo -e "${PURPLE_BOLD}Files to be removed:${NC}" echo "" - # Warn if user data is detected. - local has_user_data=false + # Warn if brew cask apps are present. + local has_brew_cask=false for detail in "${app_details[@]}"; do - IFS='|' read -r _ _ _ _ _ _ has_sensitive_data <<< "$detail" - if [[ "$has_sensitive_data" == "true" ]]; then - has_user_data=true - break - fi + IFS='|' read -r _ _ _ _ _ _ _ _ is_brew_cask_flag _ <<< "$detail" + [[ "$is_brew_cask_flag" == "true" ]] && has_brew_cask=true done - if [[ "$has_user_data" == "true" ]]; then - echo -e "${GRAY}${ICON_WARNING}${NC} ${YELLOW}Note: Some apps contain user configurations/themes${NC}" + if [[ "$has_brew_cask" == "true" ]]; then + echo -e "${GRAY}${ICON_WARNING}${NC} ${YELLOW}Homebrew apps will be fully cleaned (--zap: removes configs & data)${NC}" echo "" fi @@ -431,6 +428,7 @@ batch_uninstall_applications() { local related_files=$(decode_file_list "$encoded_files" "$app_name") local system_files=$(decode_file_list "$encoded_system_files" "$app_name") local reason="" + local suggestion="" # Show progress for current app local brew_tag="" @@ -567,7 +565,7 @@ batch_uninstall_applications() { [[ "$used_brew_successfully" == "true" ]] && ((brew_apps_removed++)) ((files_cleaned++)) ((total_items++)) - success_items+=("$app_name") + success_items+=("$app_path") else if [[ -t 1 ]]; then if [[ ${#app_details[@]} -gt 1 ]]; then @@ -593,7 +591,6 @@ batch_uninstall_applications() { local -a summary_details=() if [[ $success_count -gt 0 ]]; then - local success_list="${success_items[*]}" local success_text="app" [[ $success_count -gt 1 ]] && success_text="apps" local success_line="Removed ${success_count} ${success_text}" @@ -602,13 +599,15 @@ batch_uninstall_applications() { fi # Format app list with max 3 per line. - if [[ -n "$success_list" ]]; then + if [[ ${#success_items[@]} -gt 0 ]]; then local idx=0 local is_first_line=true local current_line="" - for app_name in "${success_items[@]}"; do - local display_item="${GREEN}${app_name}${NC}" + for success_path in "${success_items[@]}"; do + local display_name + display_name=$(basename "$success_path" .app) + local display_item="${GREEN}${display_name}${NC}" if ((idx % 3 == 0)); then if [[ -n "$current_line" ]]; then @@ -709,20 +708,8 @@ batch_uninstall_applications() { fi # Clean up Dock entries for uninstalled apps. - if [[ $success_count -gt 0 ]]; then - local -a removed_paths=() - for detail in "${app_details[@]}"; do - IFS='|' read -r app_name app_path _ _ _ _ <<< "$detail" - for success_name in "${success_items[@]}"; do - if [[ "$success_name" == "$app_name" ]]; then - removed_paths+=("$app_path") - break - fi - done - done - if [[ ${#removed_paths[@]} -gt 0 ]]; then - remove_apps_from_dock "${removed_paths[@]}" 2> /dev/null || true - fi + if [[ $success_count -gt 0 && ${#success_items[@]} -gt 0 ]]; then + remove_apps_from_dock "${success_items[@]}" 2> /dev/null || true fi _cleanup_sudo_keepalive @@ -733,18 +720,8 @@ batch_uninstall_applications() { if [[ $success_count -gt 0 ]]; then local cache_file="$HOME/.cache/mole/app_scan_cache" if [[ -f "$cache_file" ]]; then - local -a removed_paths=() - for detail in "${app_details[@]}"; do - IFS='|' read -r app_name app_path _ _ _ _ <<< "$detail" - for success_name in "${success_items[@]}"; do - if [[ "$success_name" == "$app_name" ]]; then - removed_paths+=("$app_path") - break - fi - done - done - - if [[ ${#removed_paths[@]} -gt 0 ]]; then + if [[ ${#success_items[@]} -gt 0 ]]; then + local -a removed_paths=("${success_items[@]}") local temp_cache temp_cache=$(create_temp_file) local line_removed=false From 0fb4d32bb6a9b2eb058719a1c8774324804fa171 Mon Sep 17 00:00:00 2001 From: tw93 Date: Tue, 3 Feb 2026 20:53:21 +0800 Subject: [PATCH 34/72] fix: improve whitelist pattern validation in cleanup tests --- tests/manage_whitelist.bats | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/manage_whitelist.bats b/tests/manage_whitelist.bats index 234f4fb..fdaa659 100644 --- a/tests/manage_whitelist.bats +++ b/tests/manage_whitelist.bats @@ -102,16 +102,17 @@ setup() { run bash --noprofile --norc -c "cd '$PROJECT_ROOT'; printf \$'\\n' | HOME='$HOME' ./mo clean --whitelist" [ "$status" -eq 0 ] - grep -q "\\.m2/repository" "$whitelist_file" + first_pattern=$(grep -v '^[[:space:]]*#' "$whitelist_file" | grep -v '^[[:space:]]*$' | head -n 1) + [ -n "$first_pattern" ] run bash --noprofile --norc -c "cd '$PROJECT_ROOT'; printf \$' \\n' | HOME='$HOME' ./mo clean --whitelist" [ "$status" -eq 0 ] - run grep -q "\\.m2/repository" "$whitelist_file" + run grep -Fxq "$first_pattern" "$whitelist_file" [ "$status" -eq 1 ] run bash --noprofile --norc -c "cd '$PROJECT_ROOT'; printf \$'\\n' | HOME='$HOME' ./mo clean --whitelist" [ "$status" -eq 0 ] - run grep -q "\\.m2/repository" "$whitelist_file" + run grep -Fxq "$first_pattern" "$whitelist_file" [ "$status" -eq 1 ] } From a4e084a4edb1d603c115766af088f4f3fbf7a0e2 Mon Sep 17 00:00:00 2001 From: tw93 Date: Wed, 4 Feb 2026 16:17:36 +0800 Subject: [PATCH 35/72] feat: improve app cleanup with orphaned LaunchAgent detection New features: - Add orphaned LaunchAgent/LaunchDaemon detection with 5-layer verification - Layer 1: Check if program path exists - Layer 2: Verify AssociatedBundleIdentifiers via mdfind - Layer 3: Check Application Support directory activity (7 days) - Layer 4: Fuzzy match app name in /Applications - Layer 5: Special handling for PrivilegedHelperTools - Only process user-level ~/Library/LaunchAgents (safer than system-level) - Unload agent before removal using launchctl Bug fixes: - Handle paths with spaces correctly in orphaned_app_data cleanup - Add nullglob state management to prevent word splitting - Use IFS=$'\n' for proper array iteration - Only count successful deletions (check safe_clean return value) Tests: - Add 4 new tests for is_launch_item_orphaned edge cases - Add tests for space handling and deletion count accuracy --- bin/clean.sh | 1 + lib/clean/apps.sh | 216 ++++++++++++++++++++++++++++++++- tests/clean_apps.bats | 269 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 479 insertions(+), 7 deletions(-) diff --git a/bin/clean.sh b/bin/clean.sh index 6d11602..36d114f 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -970,6 +970,7 @@ perform_cleanup() { start_section "Uninstalled app data" clean_orphaned_app_data clean_orphaned_system_services + clean_orphaned_launch_agents end_section # ===== 13. Apple Silicon optimizations ===== diff --git a/lib/clean/apps.sh b/lib/clean/apps.sh index e9f193c..adedcae 100644 --- a/lib/clean/apps.sh +++ b/lib/clean/apps.sh @@ -1,6 +1,8 @@ #!/bin/bash # Application Data Cleanup Module set -euo pipefail + +readonly ORPHAN_AGE_THRESHOLD=${ORPHAN_AGE_THRESHOLD:-${MOLE_ORPHAN_AGE_DAYS:-60}} # Args: $1=target_dir, $2=label clean_ds_store_tree() { local target="$1" @@ -282,9 +284,19 @@ clean_orphaned_app_data() { file_patterns+=("$base_path/$pat") done if [[ ${#file_patterns[@]} -gt 0 ]]; then + local _nullglob_state + _nullglob_state=$(shopt -p nullglob || true) + shopt -s nullglob for item_path in "${file_patterns[@]}"; do local iteration_count=0 - for match in $item_path; do + local old_ifs=$IFS + IFS=$'\n' + local -a matches=($item_path) + IFS=$old_ifs + if [[ ${#matches[@]} -eq 0 ]]; then + continue + fi + for match in "${matches[@]}"; do [[ -e "$match" ]] || continue ((iteration_count++)) if [[ $iteration_count -gt $MOLE_MAX_ORPHAN_ITERATIONS ]]; then @@ -299,12 +311,14 @@ clean_orphaned_app_data() { if [[ -z "$size_kb" || "$size_kb" == "0" ]]; then continue fi - safe_clean "$match" "Orphaned $label: $bundle_id" - ((orphaned_count++)) - ((total_orphaned_kb += size_kb)) + if safe_clean "$match" "Orphaned $label: $bundle_id"; then + ((orphaned_count++)) + ((total_orphaned_kb += size_kb)) + fi fi done done + eval "$_nullglob_state" fi done stop_section_spinner @@ -517,3 +531,197 @@ clean_orphaned_system_services() { fi } + +# ============================================================================ +# Orphaned LaunchAgent/LaunchDaemon Cleanup (Generic Detection) +# ============================================================================ + +# Extract program path from plist (supports both ProgramArguments and Program) +_extract_program_path() { + local plist="$1" + local program="" + + program=$(plutil -extract ProgramArguments.0 raw "$plist" 2> /dev/null) + if [[ -z "$program" ]]; then + program=$(plutil -extract Program raw "$plist" 2> /dev/null) + fi + + echo "$program" +} + +# Extract associated bundle identifier from plist +_extract_associated_bundle() { + local plist="$1" + local associated="" + + # Try array format first + associated=$(plutil -extract AssociatedBundleIdentifiers.0 raw "$plist" 2> /dev/null) + if [[ -z "$associated" ]] || [[ "$associated" == "1" ]]; then + # Try string format + associated=$(plutil -extract AssociatedBundleIdentifiers raw "$plist" 2> /dev/null) + # Filter out dict/array markers + if [[ "$associated" == "{"* ]] || [[ "$associated" == "["* ]]; then + associated="" + fi + fi + + echo "$associated" +} + +# Check if a LaunchAgent/LaunchDaemon is orphaned using multi-layer verification +# Returns 0 if orphaned, 1 if not orphaned +is_launch_item_orphaned() { + local plist="$1" + + # Layer 1: Check if program path exists + local program=$(_extract_program_path "$plist") + + # No program path - skip (not a standard launch item) + [[ -z "$program" ]] && return 1 + + # Program exists -> not orphaned + [[ -e "$program" ]] && return 1 + + # Layer 2: Check AssociatedBundleIdentifiers + local associated=$(_extract_associated_bundle "$plist") + if [[ -n "$associated" ]]; then + # Check if associated app exists via mdfind + if run_with_timeout 2 mdfind "kMDItemCFBundleIdentifier == '$associated'" 2> /dev/null | head -1 | grep -q .; then + return 1 # Associated app found -> not orphaned + fi + + # Extract vendor name from bundle ID (com.vendor.app -> vendor) + local vendor=$(echo "$associated" | cut -d'.' -f2) + if [[ -n "$vendor" ]] && [[ ${#vendor} -ge 3 ]]; then + # Check if any app from this vendor exists + if find /Applications ~/Applications -maxdepth 2 -iname "*${vendor}*" -type d 2> /dev/null | grep -iq "\.app"; then + return 1 # Vendor app exists -> not orphaned + fi + fi + fi + + # Layer 3: Check Application Support directory activity + if [[ "$program" =~ /Library/Application\ Support/([^/]+)/ ]]; then + local app_support_name="${BASH_REMATCH[1]}" + + # Check both user and system Application Support + for base in "$HOME/Library/Application Support" "/Library/Application Support"; do + local support_path="$base/$app_support_name" + if [[ -d "$support_path" ]]; then + # Check if there are files modified in last 7 days (active usage) + local recent_file=$(find "$support_path" -type f -mtime -7 2> /dev/null | head -1) + if [[ -n "$recent_file" ]]; then + return 1 # Active Application Support -> not orphaned + fi + fi + done + fi + + # Layer 4: Check if app name from program path exists + if [[ "$program" =~ /Applications/([^/]+)\.app/ ]]; then + local app_name="${BASH_REMATCH[1]}" + # Look for apps with similar names (case-insensitive) + if find /Applications ~/Applications -maxdepth 2 -iname "*${app_name}*" -type d 2> /dev/null | grep -iq "\.app"; then + return 1 # Similar app exists -> not orphaned + fi + fi + + # Layer 5: PrivilegedHelper special handling + if [[ "$program" =~ ^/Library/PrivilegedHelperTools/ ]]; then + local filename=$(basename "$plist") + local bundle_id="${filename%.plist}" + + # Extract app hint from bundle ID (com.vendor.app.helper -> vendor) + local app_hint=$(echo "$bundle_id" | sed 's/com\.//; s/\..*helper.*//') + + if [[ -n "$app_hint" ]] && [[ ${#app_hint} -ge 3 ]]; then + # Look for main app + if find /Applications ~/Applications -maxdepth 2 -iname "*${app_hint}*" -type d 2> /dev/null | grep -iq "\.app"; then + return 1 # Helper's main app exists -> not orphaned + fi + fi + fi + + # All checks failed -> likely orphaned + return 0 +} + +# Clean orphaned user-level LaunchAgents +# Only processes ~/Library/LaunchAgents (safer than system-level) +clean_orphaned_launch_agents() { + local launch_agents_dir="$HOME/Library/LaunchAgents" + + [[ ! -d "$launch_agents_dir" ]] && return 0 + + start_section_spinner "Scanning orphaned launch agents..." + + local -a orphaned_items=() + local total_orphaned_kb=0 + + # Scan user LaunchAgents + while IFS= read -r -d '' plist; do + local filename=$(basename "$plist") + + # Skip Apple's LaunchAgents + [[ "$filename" == com.apple.* ]] && continue + + local bundle_id="${filename%.plist}" + + # Check if orphaned using multi-layer verification + if is_launch_item_orphaned "$plist"; then + local size_kb=$(get_path_size_kb "$plist") + orphaned_items+=("$bundle_id|$plist") + ((total_orphaned_kb += size_kb)) + fi + done < <(find "$launch_agents_dir" -maxdepth 1 -name "*.plist" -print0 2> /dev/null) + + stop_section_spinner + + local orphaned_count=${#orphaned_items[@]} + + if [[ $orphaned_count -eq 0 ]]; then + return 0 + fi + + # Clean the orphaned items automatically + local removed_count=0 + local dry_run_count=0 + local is_dry_run=false + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + is_dry_run=true + fi + for item in "${orphaned_items[@]}"; do + IFS='|' read -r bundle_id plist_path <<< "$item" + + if [[ "$is_dry_run" == "true" ]]; then + ((dry_run_count++)) + log_operation "clean" "DRY_RUN" "$plist_path" "orphaned launch agent" + continue + fi + + # Try to unload first (if currently loaded) + launchctl unload "$plist_path" 2> /dev/null || true + + # Remove the plist file + if safe_remove "$plist_path" false; then + ((removed_count++)) + log_operation "clean" "REMOVED" "$plist_path" "orphaned launch agent" + else + log_operation "clean" "FAILED" "$plist_path" "permission denied" + fi + done + + if [[ "$is_dry_run" == "true" ]]; then + if [[ $dry_run_count -gt 0 ]]; then + local cleaned_mb=$(echo "$total_orphaned_kb" | awk '{printf "%.1f", $1/1024}') + echo " ${YELLOW}${ICON_DRY_RUN}${NC} Would remove $dry_run_count orphaned launch agent(s), ${cleaned_mb}MB" + note_activity + fi + else + if [[ $removed_count -gt 0 ]]; then + local cleaned_mb=$(echo "$total_orphaned_kb" | awk '{printf "%.1f", $1/1024}') + echo " ${GREEN}${ICON_SUCCESS}${NC} Removed $removed_count orphaned launch agent(s), ${cleaned_mb}MB" + note_activity + fi + fi +} diff --git a/tests/clean_apps.bats b/tests/clean_apps.bats index 8de3f71..6308f0e 100644 --- a/tests/clean_apps.bats +++ b/tests/clean_apps.bats @@ -80,15 +80,137 @@ EOF set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/apps.sh" -ls() { return 1; } -stop_section_spinner() { :; } +rm -rf "$HOME/Library/Caches" clean_orphaned_app_data EOF [ "$status" -eq 0 ] - [[ "$output" == *"Skipped: No permission"* ]] + [[ "$output" == *"No permission"* ]] } +@test "clean_orphaned_app_data handles paths with spaces correctly" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +# Mock scan_installed_apps - return empty (no installed apps) +scan_installed_apps() { + : > "$1" +} + +# Mock mdfind to return empty (no app found) +mdfind() { + return 0 +} + +# Ensure local function mock works even if timeout/gtimeout is installed +run_with_timeout() { shift; "$@"; } + +# Mock safe_clean (normally from bin/clean.sh) +safe_clean() { + rm -rf "$1" + return 0 +} + +# Create required Library structure for permission check +mkdir -p "$HOME/Library/Caches" + +# Create test structure with spaces in path (old modification time: 61 days ago) +mkdir -p "$HOME/Library/Saved Application State/com.test.orphan.savedState" +# Create a file with some content so directory size > 0 +echo "test data" > "$HOME/Library/Saved Application State/com.test.orphan.savedState/data.plist" +# Set modification time to 61 days ago (older than 60-day threshold) +touch -t "$(date -v-61d +%Y%m%d%H%M.%S 2>/dev/null || date -d '61 days ago' +%Y%m%d%H%M.%S)" "$HOME/Library/Saved Application State/com.test.orphan.savedState" 2>/dev/null || true + +# Disable spinner for test +start_section_spinner() { :; } +stop_section_spinner() { :; } + +# Run cleanup +clean_orphaned_app_data + +# Verify path with spaces was handled correctly (not split into multiple paths) +if [[ -d "$HOME/Library/Saved Application State/com.test.orphan.savedState" ]]; then + echo "ERROR: Orphaned savedState not deleted" + exit 1 +else + echo "SUCCESS: Orphaned savedState deleted correctly" +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"SUCCESS"* ]] +} + +@test "clean_orphaned_app_data only counts successful deletions" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +# Mock scan_installed_apps - return empty +scan_installed_apps() { + : > "$1" +} + +# Mock mdfind to return empty (no app found) +mdfind() { + return 0 +} + +# Ensure local function mock works even if timeout/gtimeout is installed +run_with_timeout() { shift; "$@"; } + +# Create required Library structure for permission check +mkdir -p "$HOME/Library/Caches" + +# Create test files (old modification time: 61 days ago) +mkdir -p "$HOME/Library/Caches/com.test.orphan1" +mkdir -p "$HOME/Library/Caches/com.test.orphan2" +# Create files with content so size > 0 +echo "data1" > "$HOME/Library/Caches/com.test.orphan1/data" +echo "data2" > "$HOME/Library/Caches/com.test.orphan2/data" +# Set modification time to 61 days ago +touch -t "$(date -v-61d +%Y%m%d%H%M.%S 2>/dev/null || date -d '61 days ago' +%Y%m%d%H%M.%S)" "$HOME/Library/Caches/com.test.orphan1" 2>/dev/null || true +touch -t "$(date -v-61d +%Y%m%d%H%M.%S 2>/dev/null || date -d '61 days ago' +%Y%m%d%H%M.%S)" "$HOME/Library/Caches/com.test.orphan2" 2>/dev/null || true + +# Mock safe_clean to fail on first item, succeed on second +safe_clean() { + if [[ "$1" == *"orphan1"* ]]; then + return 1 # Fail + else + rm -rf "$1" + return 0 # Succeed + fi +} + +# Disable spinner +start_section_spinner() { :; } +stop_section_spinner() { :; } + +# Run cleanup +clean_orphaned_app_data + +# Verify first item still exists (safe_clean failed) +if [[ -d "$HOME/Library/Caches/com.test.orphan1" ]]; then + echo "PASS: Failed deletion preserved" +fi + +# Verify second item deleted +if [[ ! -d "$HOME/Library/Caches/com.test.orphan2" ]]; then + echo "PASS: Successful deletion removed" +fi + +# Check that output shows correct count (only 1, not 2) +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"PASS: Failed deletion preserved"* ]] + [[ "$output" == *"PASS: Successful deletion removed"* ]] +} + + @test "is_critical_system_component matches known system services" { run bash --noprofile --norc <<'EOF' set -euo pipefail @@ -160,3 +282,144 @@ EOF [[ "$output" != *"rm-called"* ]] [[ "$output" != *"launchctl-called"* ]] } + +@test "is_launch_item_orphaned detects orphan when program missing" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +tmp_dir="$(mktemp -d)" +tmp_plist="$tmp_dir/com.test.orphan.plist" + +cat > "$tmp_plist" << 'PLIST' + + + + + Label + com.test.orphan + ProgramArguments + + /nonexistent/app/program + + + +PLIST + +run_with_timeout() { shift; "$@"; } + +if is_launch_item_orphaned "$tmp_plist"; then + echo "orphan" +fi + +rm -rf "$tmp_dir" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"orphan"* ]] +} + +@test "is_launch_item_orphaned protects when program exists" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +tmp_dir="$(mktemp -d)" +tmp_plist="$tmp_dir/com.test.active.plist" +tmp_program="$tmp_dir/program" +touch "$tmp_program" + +cat > "$tmp_plist" << PLIST + + + + + Label + com.test.active + ProgramArguments + + $tmp_program + + + +PLIST + +run_with_timeout() { shift; "$@"; } + +if is_launch_item_orphaned "$tmp_plist"; then + echo "orphan" +else + echo "not-orphan" +fi + +rm -rf "$tmp_dir" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"not-orphan"* ]] +} + +@test "is_launch_item_orphaned protects when app support active" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +tmp_dir="$(mktemp -d)" +tmp_plist="$tmp_dir/com.test.appsupport.plist" + +mkdir -p "$HOME/Library/Application Support/TestApp" +touch "$HOME/Library/Application Support/TestApp/recent.txt" + +cat > "$tmp_plist" << 'PLIST' + + + + + Label + com.test.appsupport + ProgramArguments + + $HOME/Library/Application Support/TestApp/Current/app + + + +PLIST + +run_with_timeout() { shift; "$@"; } + +if is_launch_item_orphaned "$tmp_plist"; then + echo "orphan" +else + echo "not-orphan" +fi + +rm -rf "$tmp_dir" +rm -rf "$HOME/Library/Application Support/TestApp" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"not-orphan"* ]] +} + +@test "clean_orphaned_launch_agents skips when no orphans" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +mkdir -p "$HOME/Library/LaunchAgents" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +get_path_size_kb() { echo "1"; } +run_with_timeout() { shift; "$@"; } + +clean_orphaned_launch_agents +EOF + + [ "$status" -eq 0 ] +} From 0fbf2661c8e8d133df5468d7f71d83d57092f90e Mon Sep 17 00:00:00 2001 From: tw93 Date: Wed, 4 Feb 2026 16:18:06 +0800 Subject: [PATCH 36/72] perf: optimize system cleanup by merging find operations Performance improvements: - Merge 3 separate find operations into 1 for /Library/Caches - Combine *.cache, *.tmp, *.log patterns in single scan - Reduces filesystem traversal overhead - Merge 2 find operations into 1 for /private/var/log - Combine *.log and *.gz patterns - Optimize diagnostics cleanup with single combined scan - Merge Special, Persist, and tracev3 patterns - Reduces redundant directory traversal - Use find -delete for batch removal of memory exception reports - More efficient than iterative removal for large file counts - Add summary logging to operations.log UI improvements: - Add granular spinner messages for each cleanup stage - Separate diagnostic logs and power logs output for clarity - Add progress feedback during Time Machine status check Tests: - Update sudo mock functions to support new combined find patterns - Verify find -delete usage for memory exception cleanup - Update assertions to match optimized implementation --- lib/clean/system.sh | 107 ++++++++++++++++++++++------ tests/clean_system_maintenance.bats | 61 ++++++++++++++-- 2 files changed, 141 insertions(+), 27 deletions(-) diff --git a/lib/clean/system.sh b/lib/clean/system.sh index 086c51c..13be78f 100644 --- a/lib/clean/system.sh +++ b/lib/clean/system.sh @@ -5,19 +5,47 @@ set -euo pipefail clean_deep_system() { stop_section_spinner local cache_cleaned=0 - safe_sudo_find_delete "/Library/Caches" "*.cache" "$MOLE_TEMP_FILE_AGE_DAYS" "f" && cache_cleaned=1 || true - safe_sudo_find_delete "/Library/Caches" "*.tmp" "$MOLE_TEMP_FILE_AGE_DAYS" "f" && cache_cleaned=1 || true - safe_sudo_find_delete "/Library/Caches" "*.log" "$MOLE_LOG_AGE_DAYS" "f" && cache_cleaned=1 || true + # Optimized: Single pass for /Library/Caches (3 patterns in 1 scan) + if sudo test -d "/Library/Caches" 2> /dev/null; then + while IFS= read -r -d '' file; do + if should_protect_path "$file"; then + continue + fi + if safe_sudo_remove "$file"; then + cache_cleaned=1 + fi + done < <(sudo find "/Library/Caches" -maxdepth 5 -type f \( \ + \( -name "*.cache" -mtime "+$MOLE_TEMP_FILE_AGE_DAYS" \) -o \ + \( -name "*.tmp" -mtime "+$MOLE_TEMP_FILE_AGE_DAYS" \) -o \ + \( -name "*.log" -mtime "+$MOLE_LOG_AGE_DAYS" \) \ + \) -print0 2> /dev/null || true) + fi [[ $cache_cleaned -eq 1 ]] && log_success "System caches" + start_section_spinner "Cleaning system temporary files..." local tmp_cleaned=0 safe_sudo_find_delete "/private/tmp" "*" "${MOLE_TEMP_FILE_AGE_DAYS}" "f" && tmp_cleaned=1 || true safe_sudo_find_delete "/private/var/tmp" "*" "${MOLE_TEMP_FILE_AGE_DAYS}" "f" && tmp_cleaned=1 || true + stop_section_spinner [[ $tmp_cleaned -eq 1 ]] && log_success "System temp files" + start_section_spinner "Cleaning system crash reports..." safe_sudo_find_delete "/Library/Logs/DiagnosticReports" "*" "$MOLE_CRASH_REPORT_AGE_DAYS" "f" || true + stop_section_spinner log_success "System crash reports" - safe_sudo_find_delete "/private/var/log" "*.log" "$MOLE_LOG_AGE_DAYS" "f" || true - safe_sudo_find_delete "/private/var/log" "*.gz" "$MOLE_LOG_AGE_DAYS" "f" || true + start_section_spinner "Cleaning system logs..." + # Optimized: Single pass for /private/var/log (2 patterns in 1 scan) + if sudo test -d "/private/var/log" 2> /dev/null; then + while IFS= read -r -d '' file; do + if should_protect_path "$file"; then + continue + fi + safe_sudo_remove "$file" || true + done < <(sudo find "/private/var/log" -maxdepth 5 -type f \( \ + -name "*.log" -o -name "*.gz" \ + \) -mtime "+$MOLE_LOG_AGE_DAYS" -print0 2> /dev/null || true) + fi + stop_section_spinner log_success "System logs" + start_section_spinner "Scanning system library updates..." if [[ -d "/Library/Updates" && ! -L "/Library/Updates" ]]; then local updates_cleaned=0 while IFS= read -r -d '' item; do @@ -34,8 +62,12 @@ clean_deep_system() { ((updates_cleaned++)) fi done < <(find /Library/Updates -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) + stop_section_spinner [[ $updates_cleaned -gt 0 ]] && log_success "System library updates" + else + stop_section_spinner fi + start_section_spinner "Scanning macOS installer files..." if [[ -d "/macOS Install Data" ]]; then local mtime=$(get_file_mtime "/macOS Install Data") local age_days=$((($(get_epoch_seconds) - mtime) / 86400)) @@ -81,6 +113,7 @@ clean_deep_system() { fi fi done + stop_section_spinner [[ $installer_cleaned -gt 0 ]] && debug_log "Cleaned $installer_cleaned macOS installer(s)" start_section_spinner "Scanning system caches..." local code_sign_cleaned=0 @@ -107,23 +140,54 @@ clean_deep_system() { stop_section_spinner [[ $code_sign_cleaned -gt 0 ]] && log_success "Browser code signature caches, $code_sign_cleaned items" - start_section_spinner "Cleaning system diagnostic logs..." - local diag_cleaned=0 - safe_sudo_find_delete "/private/var/db/diagnostics/Special" "*" "$MOLE_LOG_AGE_DAYS" "f" && diag_cleaned=1 || true - safe_sudo_find_delete "/private/var/db/diagnostics/Persist" "*" "$MOLE_LOG_AGE_DAYS" "f" && diag_cleaned=1 || true - safe_sudo_find_delete "/private/var/db/DiagnosticPipeline" "*" "$MOLE_LOG_AGE_DAYS" "f" && diag_cleaned=1 || true - safe_sudo_find_delete "/private/var/db/powerlog" "*" "$MOLE_LOG_AGE_DAYS" "f" && diag_cleaned=1 || true - safe_sudo_find_delete "/private/var/db/reportmemoryexception/MemoryLimitViolations" "*" "30" "f" && diag_cleaned=1 || true - stop_section_spinner + # Optimized: Single pass for diagnostics directory (Special + Persist + tracev3) + # Replaces 4 separate find operations with 1 combined operation + local diag_base="/private/var/db/diagnostics" + if sudo test -d "$diag_base" 2> /dev/null; then + while IFS= read -r -d '' file; do + if should_protect_path "$file"; then + continue + fi + safe_sudo_remove "$file" || true + done < <(sudo find "$diag_base" -maxdepth 5 -type f \( \ + \( -mtime "+$MOLE_LOG_AGE_DAYS" \) -o \ + \( -name "*.tracev3" -mtime +30 \) \ + \) -print0 2> /dev/null || true) + fi + safe_sudo_find_delete "/private/var/db/DiagnosticPipeline" "*" "$MOLE_LOG_AGE_DAYS" "f" || true + log_success "System diagnostic logs" + safe_sudo_find_delete "/private/var/db/powerlog" "*" "$MOLE_LOG_AGE_DAYS" "f" || true + log_success "Power logs" + start_section_spinner "Cleaning memory exception reports..." + local mem_reports_dir="/private/var/db/reportmemoryexception/MemoryLimitViolations" + if sudo test -d "$mem_reports_dir" 2> /dev/null; then + # Count and size old files before deletion + local file_count=0 + local total_size_kb=0 + while IFS= read -r -d '' file; do + ((file_count++)) + local file_size + file_size=$(sudo stat -f%z "$file" 2> /dev/null || echo "0") + ((total_size_kb += file_size / 1024)) + done < <(sudo find "$mem_reports_dir" -type f -mtime +30 -print0 2> /dev/null || true) - [[ $diag_cleaned -eq 1 ]] && log_success "System diagnostic logs" - - start_section_spinner "Cleaning diagnostic trace logs..." - local trace_cleaned=0 - safe_sudo_find_delete "/private/var/db/diagnostics/Persist" "*.tracev3" "30" "f" && trace_cleaned=1 || true - safe_sudo_find_delete "/private/var/db/diagnostics/Special" "*.tracev3" "30" "f" && trace_cleaned=1 || true + # For directories with many files, use find -delete for performance + if [[ "$file_count" -gt 0 ]]; then + if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then + sudo find "$mem_reports_dir" -type f -mtime +30 -delete 2> /dev/null || true + # Log summary to operations.log + if oplog_enabled && [[ "$total_size_kb" -gt 0 ]]; then + local size_human + size_human=$(bytes_to_human "$((total_size_kb * 1024))") + log_operation "[clean] REMOVED $mem_reports_dir ($file_count files, $size_human)" + fi + else + log_info "[DRY-RUN] Would remove $file_count old memory exception reports ($total_size_kb KB)" + fi + fi + fi stop_section_spinner - [[ $trace_cleaned -eq 1 ]] && log_success "System diagnostic trace logs" + log_success "Memory exception reports" } # Incomplete Time Machine backups. clean_time_machine_failed_backups() { @@ -304,15 +368,18 @@ clean_local_snapshots() { return 0 fi + start_section_spinner "Checking Time Machine status..." local rc_running=0 tm_is_running || rc_running=$? if [[ $rc_running -eq 2 ]]; then + stop_section_spinner echo -e " ${YELLOW}!${NC} Could not determine Time Machine status; skipping snapshot check" return 0 fi if [[ $rc_running -eq 0 ]]; then + stop_section_spinner echo -e " ${YELLOW}!${NC} Time Machine is active; skipping snapshot check" return 0 fi diff --git a/tests/clean_system_maintenance.bats b/tests/clean_system_maintenance.bats index 97f2b5a..46f52ed 100644 --- a/tests/clean_system_maintenance.bats +++ b/tests/clean_system_maintenance.bats @@ -28,7 +28,23 @@ CALL_LOG="$HOME/system_calls.log" source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/system.sh" -sudo() { return 0; } +sudo() { + if [[ "$1" == "test" ]]; then + return 0 + fi + if [[ "$1" == "find" ]]; then + case "$2" in + /Library/Caches) printf '%s\0' "/Library/Caches/test.log" ;; + /private/var/log) printf '%s\0' "/private/var/log/system.log" ;; + esac + return 0 + fi + if [[ "$1" == "stat" ]]; then + echo "0" + return 0 + fi + return 0 +} safe_sudo_find_delete() { echo "safe_sudo_find_delete:$1:$2" >> "$CALL_LOG" return 0 @@ -562,11 +578,24 @@ CALL_LOG="$HOME/memory_exception_calls.log" source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/system.sh" -sudo() { return 0; } -safe_sudo_find_delete() { - echo "safe_sudo_find_delete:$1:$2:$3:$4" >> "$CALL_LOG" +sudo() { + if [[ "$1" == "test" ]]; then + return 0 + fi + if [[ "$1" == "find" ]]; then + echo "sudo_find:$*" >> "$CALL_LOG" + if [[ "$2" == "/private/var/db/reportmemoryexception/MemoryLimitViolations" && "$*" != *"-delete"* ]]; then + printf '%s\0' "/private/var/db/reportmemoryexception/MemoryLimitViolations/report.bin" + fi + return 0 + fi + if [[ "$1" == "stat" ]]; then + echo "1024" + return 0 + fi return 0 } +safe_sudo_find_delete() { return 0; } safe_sudo_remove() { return 0; } log_success() { :; } is_sip_enabled() { return 1; } @@ -579,7 +608,8 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"reportmemoryexception/MemoryLimitViolations"* ]] - [[ "$output" == *":30:"* ]] # 30-day retention + [[ "$output" == *"-mtime +30"* ]] # 30-day retention + [[ "$output" == *"-delete"* ]] } @test "clean_deep_system cleans diagnostic trace logs" { @@ -590,12 +620,29 @@ CALL_LOG="$HOME/diag_calls.log" source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/system.sh" -sudo() { return 0; } +sudo() { + if [[ "$1" == "test" ]]; then + return 0 + fi + if [[ "$1" == "find" ]]; then + echo "sudo_find:$*" >> "$CALL_LOG" + if [[ "$2" == "/private/var/db/diagnostics" ]]; then + printf '%s\0' \ + "/private/var/db/diagnostics/Persist/test.tracev3" \ + "/private/var/db/diagnostics/Special/test.tracev3" + fi + return 0 + fi + return 0 +} safe_sudo_find_delete() { echo "safe_sudo_find_delete:$1:$2" >> "$CALL_LOG" return 0 } -safe_sudo_remove() { return 0; } +safe_sudo_remove() { + echo "safe_sudo_remove:$1" >> "$CALL_LOG" + return 0 +} log_success() { :; } start_section_spinner() { :; } stop_section_spinner() { :; } From 41a26204fbc1edec8fc4842d78bd6045e684cb1f Mon Sep 17 00:00:00 2001 From: tw93 Date: Wed, 4 Feb 2026 16:18:13 +0800 Subject: [PATCH 37/72] perf: skip redundant -name parameter when pattern is wildcard Optimization: - Skip -name "*" in safe_sudo_find_delete when pattern matches everything - Reduces unnecessary parameter passing to find command - Improves performance for operations that scan all files Rationale: - find -name "*" is redundant as it matches everything by default - Removing it reduces command overhead without changing behavior --- lib/core/file_ops.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index 3ad9f76..b2c3966 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -467,7 +467,12 @@ safe_sudo_find_delete() { debug_log "Finding, sudo, in $base_dir: $pattern, age: ${age_days}d, type: $type_filter" - local find_args=("-maxdepth" "5" "-name" "$pattern" "-type" "$type_filter") + local find_args=("-maxdepth" "5") + # Skip -name if pattern is "*" (matches everything anyway, but adds overhead) + if [[ "$pattern" != "*" ]]; then + find_args+=("-name" "$pattern") + fi + find_args+=("-type" "$type_filter") if [[ "$age_days" -gt 0 ]]; then find_args+=("-mtime" "+$age_days") fi From 8bf3d419f5bcb232e051ec5875299031ea1fb445 Mon Sep 17 00:00:00 2001 From: tw93 Date: Wed, 4 Feb 2026 16:39:02 +0800 Subject: [PATCH 38/72] fix(raycast): defer command interpolation --- scripts/setup-quick-launchers.sh | 36 +++++++++++++++++++------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/scripts/setup-quick-launchers.sh b/scripts/setup-quick-launchers.sh index 53b07f3..216a285 100755 --- a/scripts/setup-quick-launchers.sh +++ b/scripts/setup-quick-launchers.sh @@ -59,12 +59,18 @@ write_raycast_script() { # Optional parameters: # @raycast.icon 🐹 +# ────────────────────────────────────────────────────────── +# Script execution begins below +# ────────────────────────────────────────────────────────── + set -euo pipefail echo "🐹 Running ${title}..." echo "" -CMD="${raw_cmd}" -CMD_ESCAPED="${cmd_escaped}" + +# Command to execute +_MO_RAW_CMD="${raw_cmd}" +_MO_CMD_ESCAPED="${cmd_escaped}" has_app() { local name="\$1" @@ -114,7 +120,7 @@ launch_with_app() { Terminal) if command -v osascript >/dev/null 2>&1; then osascript <<'APPLESCRIPT' -set targetCommand to "${cmd_escaped}" +set targetCommand to "\${_MO_CMD_ESCAPED}" tell application "Terminal" activate do script targetCommand @@ -126,7 +132,7 @@ APPLESCRIPT iTerm|iTerm2) if command -v osascript >/dev/null 2>&1; then osascript <<'APPLESCRIPT' -set targetCommand to "${cmd_escaped}" +set targetCommand to "\${_MO_CMD_ESCAPED}" tell application "iTerm2" activate try @@ -150,52 +156,52 @@ APPLESCRIPT ;; Alacritty) if launcher_available "Alacritty" && command -v open >/dev/null 2>&1; then - open -na "Alacritty" --args -e /bin/zsh -lc "${raw_cmd}" + open -na "Alacritty" --args -e /bin/zsh -lc "\${_MO_RAW_CMD}" return \$? fi ;; Kitty) if has_bin "kitty"; then - kitty --hold /bin/zsh -lc "${raw_cmd}" + kitty --hold /bin/zsh -lc "\${_MO_RAW_CMD}" return \$? elif [[ -x "/Applications/kitty.app/Contents/MacOS/kitty" ]]; then - "/Applications/kitty.app/Contents/MacOS/kitty" --hold /bin/zsh -lc "${raw_cmd}" + "/Applications/kitty.app/Contents/MacOS/kitty" --hold /bin/zsh -lc "\${_MO_RAW_CMD}" return \$? fi ;; WezTerm) if has_bin "wezterm"; then - wezterm start -- /bin/zsh -lc "${raw_cmd}" + wezterm start -- /bin/zsh -lc "\${_MO_RAW_CMD}" return \$? elif [[ -x "/Applications/WezTerm.app/Contents/MacOS/wezterm" ]]; then - "/Applications/WezTerm.app/Contents/MacOS/wezterm" start -- /bin/zsh -lc "${raw_cmd}" + "/Applications/WezTerm.app/Contents/MacOS/wezterm" start -- /bin/zsh -lc "\${_MO_RAW_CMD}" return \$? fi ;; Ghostty) if has_bin "ghostty"; then - ghostty --command "/bin/zsh" -- -lc "${raw_cmd}" + ghostty --command "/bin/zsh" -- -lc "\${_MO_RAW_CMD}" return \$? elif [[ -x "/Applications/Ghostty.app/Contents/MacOS/ghostty" ]]; then - "/Applications/Ghostty.app/Contents/MacOS/ghostty" --command "/bin/zsh" -- -lc "${raw_cmd}" + "/Applications/Ghostty.app/Contents/MacOS/ghostty" --command "/bin/zsh" -- -lc "\${_MO_RAW_CMD}" return \$? fi ;; Hyper) if launcher_available "Hyper" && command -v open >/dev/null 2>&1; then - open -na "Hyper" --args /bin/zsh -lc "${raw_cmd}" + open -na "Hyper" --args /bin/zsh -lc "\${_MO_RAW_CMD}" return \$? fi ;; WindTerm) if launcher_available "WindTerm" && command -v open >/dev/null 2>&1; then - open -na "WindTerm" --args /bin/zsh -lc "${raw_cmd}" + open -na "WindTerm" --args /bin/zsh -lc "\${_MO_RAW_CMD}" return \$? fi ;; Warp) if launcher_available "Warp" && command -v open >/dev/null 2>&1; then - open -na "Warp" --args /bin/zsh -lc "${raw_cmd}" + open -na "Warp" --args /bin/zsh -lc "\${_MO_RAW_CMD}" return \$? fi ;; @@ -223,7 +229,7 @@ fi echo "TERM environment variable not set and no launcher succeeded." echo "Run this manually:" -echo " ${raw_cmd}" +echo " \${_MO_RAW_CMD}" exit 1 EOF chmod +x "$target" From 5edddb616b39e30a436084d2b1807c1cdc40bfae Mon Sep 17 00:00:00 2001 From: tw93 Date: Wed, 4 Feb 2026 16:43:07 +0800 Subject: [PATCH 39/72] chore(raycast): clarify settings open fallback --- scripts/setup-quick-launchers.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/setup-quick-launchers.sh b/scripts/setup-quick-launchers.sh index 216a285..9c996b7 100755 --- a/scripts/setup-quick-launchers.sh +++ b/scripts/setup-quick-launchers.sh @@ -251,10 +251,10 @@ create_raycast_commands() { log_header "Raycast Configuration" if command -v open > /dev/null 2>&1; then - if open "raycast://extensions/raycast/raycast-settings/extensions" > /dev/null 2>&1; then + if open "raycast://extensions/raycast/raycast-settings/extensions" > /dev/null 2>&1 2> /dev/null; then log_step "Raycast settings opened." else - log_warn "Could not auto-open Raycast." + log_warn "Could not auto-open Raycast settings (raycast:// URL may be unavailable). Please open Raycast manually." fi else log_warn "open command not available; please open Raycast manually." @@ -266,10 +266,10 @@ create_raycast_commands() { if is_interactive; then log_header "Finalizing Setup" prompt_enter "Press [Enter] to reload script directories in Raycast..." - if command -v open > /dev/null 2>&1 && open "raycast://extensions/raycast/raycast/reload-script-directories" > /dev/null 2>&1; then + if command -v open > /dev/null 2>&1 && open "raycast://extensions/raycast/raycast/reload-script-directories" > /dev/null 2>&1 2> /dev/null; then log_step "Raycast script directories reloaded." else - log_warn "Could not auto-reload Raycast script directories." + log_warn "Could not auto-reload Raycast script directories (raycast:// URL may be unavailable)." fi log_success "Raycast setup complete!" From 9a6427408ede401148e65fbff682f6150a5e4ec9 Mon Sep 17 00:00:00 2001 From: tw93 Date: Wed, 4 Feb 2026 16:48:15 +0800 Subject: [PATCH 40/72] chore(raycast): make setup manual --- scripts/setup-quick-launchers.sh | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/scripts/setup-quick-launchers.sh b/scripts/setup-quick-launchers.sh index 9c996b7..c60369e 100755 --- a/scripts/setup-quick-launchers.sh +++ b/scripts/setup-quick-launchers.sh @@ -250,28 +250,13 @@ create_raycast_commands() { log_success "Scripts ready in: $dir" log_header "Raycast Configuration" - if command -v open > /dev/null 2>&1; then - if open "raycast://extensions/raycast/raycast-settings/extensions" > /dev/null 2>&1 2> /dev/null; then - log_step "Raycast settings opened." - else - log_warn "Could not auto-open Raycast settings (raycast:// URL may be unavailable). Please open Raycast manually." - fi - else - log_warn "open command not available; please open Raycast manually." - fi - + log_step "Open Raycast → Settings → Extensions → Script Commands." echo "If Raycast asks to add a Script Directory, use:" echo " $dir" if is_interactive; then log_header "Finalizing Setup" - prompt_enter "Press [Enter] to reload script directories in Raycast..." - if command -v open > /dev/null 2>&1 && open "raycast://extensions/raycast/raycast/reload-script-directories" > /dev/null 2>&1 2> /dev/null; then - log_step "Raycast script directories reloaded." - else - log_warn "Could not auto-reload Raycast script directories (raycast:// URL may be unavailable)." - fi - + prompt_enter "Press [Enter] after clicking 'Reload Script Directories' in Raycast..." log_success "Raycast setup complete!" else log_warn "Non-interactive mode; skip Raycast reload. Please run 'Reload Script Directories' in Raycast." From ba1a21f8e78bca71262e0efb875f7839b25b8a6c Mon Sep 17 00:00:00 2001 From: tw93 Date: Wed, 4 Feb 2026 16:54:59 +0800 Subject: [PATCH 41/72] chore(raycast): add explicit script dir steps --- scripts/setup-quick-launchers.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/setup-quick-launchers.sh b/scripts/setup-quick-launchers.sh index c60369e..3de598e 100755 --- a/scripts/setup-quick-launchers.sh +++ b/scripts/setup-quick-launchers.sh @@ -251,12 +251,13 @@ create_raycast_commands() { log_header "Raycast Configuration" log_step "Open Raycast → Settings → Extensions → Script Commands." - echo "If Raycast asks to add a Script Directory, use:" - echo " $dir" + echo "1. Click \"+\" → Add Script Directory." + echo "2. Choose: $dir" + echo "3. Click \"Reload Script Directories\"." if is_interactive; then log_header "Finalizing Setup" - prompt_enter "Press [Enter] after clicking 'Reload Script Directories' in Raycast..." + prompt_enter "Press [Enter] after finishing the steps above in Raycast..." log_success "Raycast setup complete!" else log_warn "Non-interactive mode; skip Raycast reload. Please run 'Reload Script Directories' in Raycast." From 8861fe6b5f2cce059cad8ec81deae82b68ef0994 Mon Sep 17 00:00:00 2001 From: tw93 Date: Wed, 4 Feb 2026 16:58:39 +0800 Subject: [PATCH 42/72] chore(raycast): prompt before continuing --- scripts/setup-quick-launchers.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/setup-quick-launchers.sh b/scripts/setup-quick-launchers.sh index 3de598e..e72080d 100755 --- a/scripts/setup-quick-launchers.sh +++ b/scripts/setup-quick-launchers.sh @@ -257,7 +257,8 @@ create_raycast_commands() { if is_interactive; then log_header "Finalizing Setup" - prompt_enter "Press [Enter] after finishing the steps above in Raycast..." + log_warn "Please complete the Raycast steps above before continuing." + prompt_enter "Press [Enter] to continue..." log_success "Raycast setup complete!" else log_warn "Non-interactive mode; skip Raycast reload. Please run 'Reload Script Directories' in Raycast." From cb19899eaab28d794d33f0d0554c9525e422d0fc Mon Sep 17 00:00:00 2001 From: tw93 Date: Wed, 4 Feb 2026 17:34:04 +0800 Subject: [PATCH 43/72] fix(apps): correct array initialization for matches in clean_orphaned_app_data --- lib/clean/apps.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/clean/apps.sh b/lib/clean/apps.sh index adedcae..11d064c 100644 --- a/lib/clean/apps.sh +++ b/lib/clean/apps.sh @@ -291,7 +291,9 @@ clean_orphaned_app_data() { local iteration_count=0 local old_ifs=$IFS IFS=$'\n' - local -a matches=($item_path) + local -a matches=() + # shellcheck disable=SC2206 + matches=($item_path) IFS=$old_ifs if [[ ${#matches[@]} -eq 0 ]]; then continue From 257c63954128c423be5280b33ba95071df1a5caa Mon Sep 17 00:00:00 2001 From: Andrei Murariu <83287213+iamxorum@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:08:54 +0200 Subject: [PATCH 44/72] bug-fix: add hardcoded directories for raycast removal (#414) * bug-fix: uninstall raycast leftovers * bug-fix: add hardcoded directories for raycast removal --------- Co-authored-by: tw93 --- lib/core/app_protection.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index 90fa7dd..cc54844 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -1112,6 +1112,10 @@ find_app_files() { done < <(command find "$dir" -maxdepth 1 -type d -iname "*raycast*" -print0 2> /dev/null) done + # Explicit Raycast container directories (hardcoded leftovers) + [[ -d "$HOME/Library/Containers/com.raycast.macos.BrowserExtension" ]] && files_to_clean+=("$HOME/Library/Containers/com.raycast.macos.BrowserExtension") + [[ -d "$HOME/Library/Containers/com.raycast.macos.RaycastAppIntents" ]] && files_to_clean+=("$HOME/Library/Containers/com.raycast.macos.RaycastAppIntents") + # Cache (deeper search) [[ -d "$HOME/Library/Caches" ]] && while IFS= read -r -d '' p; do files_to_clean+=("$p") From d8b396533d06eeded48c83126c197f52c71d57e2 Mon Sep 17 00:00:00 2001 From: tw93 Date: Wed, 4 Feb 2026 19:57:44 +0800 Subject: [PATCH 45/72] fix: implement MO_USE_FIND and improve fd fallback logic Fixes the issue reported in PR #410 where `mo purge` fails to find artifacts when `fd` returns empty results. Changes: - Implement MO_USE_FIND environment variable to force using find - Improve fd fallback: check if fd output is empty (-s test) - Add debug logging to show which tool is being used - If fd returns no results, fallback to find automatically This fixes the root cause where fd successfully runs (exit 0) but finds nothing, preventing the find fallback from being triggered. --- lib/clean/project.sh | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/lib/clean/project.sh b/lib/clean/project.sh index 91944cf..628522a 100644 --- a/lib/clean/project.sh +++ b/lib/clean/project.sh @@ -377,7 +377,11 @@ scan_purge_targets() { local use_find=true - if command -v fd > /dev/null 2>&1; then + # Allow forcing find via MO_USE_FIND environment variable + if [[ "${MO_USE_FIND:-0}" == "1" ]]; then + debug "MO_USE_FIND=1: Forcing find instead of fd" + use_find=true + elif command -v fd > /dev/null 2>&1; then # Escape regex special characters in target names for fd patterns local escaped_targets=() for target in "${PURGE_TARGETS[@]}"; do @@ -404,12 +408,22 @@ scan_purge_targets() { # Try running fd. If it succeeds (exit code 0), use it. # If it fails (e.g. bad flag, permissions, binary issue), fallback to find. if fd "${fd_args[@]}" "$pattern" "$search_path" 2> /dev/null > "$output_file.raw"; then - use_find=false - process_scan_results "$output_file.raw" + # Check if fd actually found anything - if empty, fallback to find + if [[ -s "$output_file.raw" ]]; then + debug "Using fd for scanning (found results)" + use_find=false + process_scan_results "$output_file.raw" + else + debug "fd returned empty results, falling back to find" + rm -f "$output_file.raw" + fi + else + debug "fd command failed, falling back to find" fi fi if [[ "$use_find" == "true" ]]; then + debug "Using find for scanning" # Pruned find avoids descending into heavy directories. local prune_dirs=(".git" "Library" ".Trash" "Applications") local purge_targets=("${PURGE_TARGETS[@]}") From dc2f061d69ee79292d0c98fe8b0499c7194533f0 Mon Sep 17 00:00:00 2001 From: tw93 Date: Wed, 4 Feb 2026 20:01:35 +0800 Subject: [PATCH 46/72] fix(raycast): quote raw command safely --- scripts/setup-quick-launchers.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/setup-quick-launchers.sh b/scripts/setup-quick-launchers.sh index e72080d..60f77af 100755 --- a/scripts/setup-quick-launchers.sh +++ b/scripts/setup-quick-launchers.sh @@ -69,7 +69,7 @@ echo "🐹 Running ${title}..." echo "" # Command to execute -_MO_RAW_CMD="${raw_cmd}" +_MO_RAW_CMD='${raw_cmd}' _MO_CMD_ESCAPED="${cmd_escaped}" has_app() { From 7f787b5c04eaea0011c361ec4551b3d8c91b7af9 Mon Sep 17 00:00:00 2001 From: tw93 Date: Wed, 4 Feb 2026 20:05:09 +0800 Subject: [PATCH 47/72] fix: implement MO_USE_FIND and improve fd fallback logic Fixes the issue reported in PR #410 where mo purge fails to find artifacts when fd returns empty results. Changes: - Implement MO_USE_FIND environment variable to force using find - Improve fd fallback: check if fd output is empty (-s test) - Add debug logging to show which tool is being used - If fd returns no results, fallback to find automatically This fixes the root cause where fd successfully runs (exit 0) but finds nothing, preventing the find fallback from being triggered. --- lib/clean/project.sh | 10 +++++----- scripts/setup-quick-launchers.sh | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/clean/project.sh b/lib/clean/project.sh index 628522a..4c6606d 100644 --- a/lib/clean/project.sh +++ b/lib/clean/project.sh @@ -379,7 +379,7 @@ scan_purge_targets() { # Allow forcing find via MO_USE_FIND environment variable if [[ "${MO_USE_FIND:-0}" == "1" ]]; then - debug "MO_USE_FIND=1: Forcing find instead of fd" + debug_log "MO_USE_FIND=1: Forcing find instead of fd" use_find=true elif command -v fd > /dev/null 2>&1; then # Escape regex special characters in target names for fd patterns @@ -410,20 +410,20 @@ scan_purge_targets() { if fd "${fd_args[@]}" "$pattern" "$search_path" 2> /dev/null > "$output_file.raw"; then # Check if fd actually found anything - if empty, fallback to find if [[ -s "$output_file.raw" ]]; then - debug "Using fd for scanning (found results)" + debug_log "Using fd for scanning (found results)" use_find=false process_scan_results "$output_file.raw" else - debug "fd returned empty results, falling back to find" + debug_log "fd returned empty results, falling back to find" rm -f "$output_file.raw" fi else - debug "fd command failed, falling back to find" + debug_log "fd command failed, falling back to find" fi fi if [[ "$use_find" == "true" ]]; then - debug "Using find for scanning" + debug_log "Using find for scanning" # Pruned find avoids descending into heavy directories. local prune_dirs=(".git" "Library" ".Trash" "Applications") local purge_targets=("${PURGE_TARGETS[@]}") diff --git a/scripts/setup-quick-launchers.sh b/scripts/setup-quick-launchers.sh index e72080d..60f77af 100755 --- a/scripts/setup-quick-launchers.sh +++ b/scripts/setup-quick-launchers.sh @@ -69,7 +69,7 @@ echo "🐹 Running ${title}..." echo "" # Command to execute -_MO_RAW_CMD="${raw_cmd}" +_MO_RAW_CMD='${raw_cmd}' _MO_CMD_ESCAPED="${cmd_escaped}" has_app() { From c5073ec6c89875612011a063b791ba8a99722783 Mon Sep 17 00:00:00 2001 From: tw93 Date: Wed, 4 Feb 2026 20:46:28 +0800 Subject: [PATCH 48/72] fix(setup): improve command escaping for Raycast scripts --- scripts/setup-quick-launchers.sh | 45 ++++++++++++++++---------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/scripts/setup-quick-launchers.sh b/scripts/setup-quick-launchers.sh index 60f77af..889a02f 100755 --- a/scripts/setup-quick-launchers.sh +++ b/scripts/setup-quick-launchers.sh @@ -44,9 +44,10 @@ write_raycast_script() { local title="$2" local mo_bin="$3" local subcommand="$4" - local raw_cmd="\"${mo_bin}\" ${subcommand}" - local cmd_escaped="${raw_cmd//\\/\\\\}" - cmd_escaped="${cmd_escaped//\"/\\\"}" + + local cmd_for_applescript="${mo_bin//\\/\\\\}" + cmd_for_applescript="${cmd_for_applescript//\"/\\\"}" + cat > "$target" << EOF #!/bin/bash @@ -68,9 +69,9 @@ set -euo pipefail echo "🐹 Running ${title}..." echo "" -# Command to execute -_MO_RAW_CMD='${raw_cmd}' -_MO_CMD_ESCAPED="${cmd_escaped}" +MO_BIN="${mo_bin}" +MO_SUBCOMMAND="${subcommand}" +MO_BIN_ESCAPED="${cmd_for_applescript}" has_app() { local name="\$1" @@ -119,8 +120,8 @@ launch_with_app() { case "\$app" in Terminal) if command -v osascript >/dev/null 2>&1; then - osascript <<'APPLESCRIPT' -set targetCommand to "\${_MO_CMD_ESCAPED}" + osascript </dev/null 2>&1; then - osascript <<'APPLESCRIPT' -set targetCommand to "\${_MO_CMD_ESCAPED}" + osascript </dev/null 2>&1; then - open -na "Alacritty" --args -e /bin/zsh -lc "\${_MO_RAW_CMD}" + open -na "Alacritty" --args -e /bin/zsh -lc "\"\${MO_BIN}\" \${MO_SUBCOMMAND}" return \$? fi ;; Kitty) if has_bin "kitty"; then - kitty --hold /bin/zsh -lc "\${_MO_RAW_CMD}" + kitty --hold /bin/zsh -lc "\"\${MO_BIN}\" \${MO_SUBCOMMAND}" return \$? elif [[ -x "/Applications/kitty.app/Contents/MacOS/kitty" ]]; then - "/Applications/kitty.app/Contents/MacOS/kitty" --hold /bin/zsh -lc "\${_MO_RAW_CMD}" + "/Applications/kitty.app/Contents/MacOS/kitty" --hold /bin/zsh -lc "\"\${MO_BIN}\" \${MO_SUBCOMMAND}" return \$? fi ;; WezTerm) if has_bin "wezterm"; then - wezterm start -- /bin/zsh -lc "\${_MO_RAW_CMD}" + wezterm start -- /bin/zsh -lc "\"\${MO_BIN}\" \${MO_SUBCOMMAND}" return \$? elif [[ -x "/Applications/WezTerm.app/Contents/MacOS/wezterm" ]]; then - "/Applications/WezTerm.app/Contents/MacOS/wezterm" start -- /bin/zsh -lc "\${_MO_RAW_CMD}" + "/Applications/WezTerm.app/Contents/MacOS/wezterm" start -- /bin/zsh -lc "\"\${MO_BIN}\" \${MO_SUBCOMMAND}" return \$? fi ;; Ghostty) if has_bin "ghostty"; then - ghostty --command "/bin/zsh" -- -lc "\${_MO_RAW_CMD}" + ghostty --command "/bin/zsh" -- -lc "\"\${MO_BIN}\" \${MO_SUBCOMMAND}" return \$? elif [[ -x "/Applications/Ghostty.app/Contents/MacOS/ghostty" ]]; then - "/Applications/Ghostty.app/Contents/MacOS/ghostty" --command "/bin/zsh" -- -lc "\${_MO_RAW_CMD}" + "/Applications/Ghostty.app/Contents/MacOS/ghostty" --command "/bin/zsh" -- -lc "\"\${MO_BIN}\" \${MO_SUBCOMMAND}" return \$? fi ;; Hyper) if launcher_available "Hyper" && command -v open >/dev/null 2>&1; then - open -na "Hyper" --args /bin/zsh -lc "\${_MO_RAW_CMD}" + open -na "Hyper" --args /bin/zsh -lc "\"\${MO_BIN}\" \${MO_SUBCOMMAND}" return \$? fi ;; WindTerm) if launcher_available "WindTerm" && command -v open >/dev/null 2>&1; then - open -na "WindTerm" --args /bin/zsh -lc "\${_MO_RAW_CMD}" + open -na "WindTerm" --args /bin/zsh -lc "\"\${MO_BIN}\" \${MO_SUBCOMMAND}" return \$? fi ;; Warp) if launcher_available "Warp" && command -v open >/dev/null 2>&1; then - open -na "Warp" --args /bin/zsh -lc "\${_MO_RAW_CMD}" + open -na "Warp" --args /bin/zsh -lc "\"\${MO_BIN}\" \${MO_SUBCOMMAND}" return \$? fi ;; @@ -210,7 +211,7 @@ APPLESCRIPT } if [[ -n "\${TERM:-}" && "\${TERM}" != "dumb" ]]; then - "${mo_bin}" ${subcommand} + "\${MO_BIN}" \${MO_SUBCOMMAND} exit \$? fi @@ -229,7 +230,7 @@ fi echo "TERM environment variable not set and no launcher succeeded." echo "Run this manually:" -echo " \${_MO_RAW_CMD}" +echo " \"\${MO_BIN}\" \${MO_SUBCOMMAND}" exit 1 EOF chmod +x "$target" From dfa586d951e31d6f13afcafff9aad1a018db3232 Mon Sep 17 00:00:00 2001 From: tw93 Date: Thu, 5 Feb 2026 11:33:19 +0800 Subject: [PATCH 49/72] fix(raycast): use correct macOS launch method for Ghostty (#415) Ghostty CLI on macOS cannot launch terminal windows, causing cli:N field parse errors. Use `open -na Ghostty --args -e` instead. --- scripts/setup-quick-launchers.sh | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/scripts/setup-quick-launchers.sh b/scripts/setup-quick-launchers.sh index 889a02f..af0984e 100755 --- a/scripts/setup-quick-launchers.sh +++ b/scripts/setup-quick-launchers.sh @@ -180,11 +180,8 @@ APPLESCRIPT fi ;; Ghostty) - if has_bin "ghostty"; then - ghostty --command "/bin/zsh" -- -lc "\"\${MO_BIN}\" \${MO_SUBCOMMAND}" - return \$? - elif [[ -x "/Applications/Ghostty.app/Contents/MacOS/ghostty" ]]; then - "/Applications/Ghostty.app/Contents/MacOS/ghostty" --command "/bin/zsh" -- -lc "\"\${MO_BIN}\" \${MO_SUBCOMMAND}" + if launcher_available "Ghostty" && command -v open >/dev/null 2>&1; then + open -na "Ghostty" --args -e /bin/zsh -lc "\${MO_BIN} \${MO_SUBCOMMAND}" return \$? fi ;; From a0d5b476d3403ab8fe192a3d5c43aaea1b7d2394 Mon Sep 17 00:00:00 2001 From: tw93 Date: Thu, 5 Feb 2026 11:40:00 +0800 Subject: [PATCH 50/72] fix(analyze): skip virtualization/container mounts to prevent NFS hangs - Add OrbStack, Colima, Parallels, VMware Fusion, VirtualBox, Rancher Desktop to skip list - Prevent infinite NFS timeout loops when scanning VM/container mount points - Fixes #416 --- cmd/analyze/constants.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cmd/analyze/constants.go b/cmd/analyze/constants.go index fd6591b..36301ab 100644 --- a/cmd/analyze/constants.go +++ b/cmd/analyze/constants.go @@ -187,6 +187,17 @@ var defaultSkipDirs = map[string]bool{ "nfs": true, "PHD": true, "Permissions": true, + + // Virtualization/Container mounts (NFS, network filesystems). + "OrbStack": true, // OrbStack NFS mounts + "Colima": true, // Colima VM mounts + "Parallels": true, // Parallels Desktop VMs + "VMware Fusion": true, // VMware Fusion VMs + "VirtualBox VMs": true, // VirtualBox VMs + "Rancher Desktop": true, // Rancher Desktop mounts + ".lima": true, // Lima VM mounts + ".colima": true, // Colima config/mounts + ".orbstack": true, // OrbStack config/mounts } var skipExtensions = map[string]bool{ From 30777dafa33188d125813d05ed1cfe2520678b99 Mon Sep 17 00:00:00 2001 From: tw93 Date: Thu, 5 Feb 2026 19:50:16 +0800 Subject: [PATCH 51/72] fix(ui): stop treating space as search filter in paginated menu --- lib/ui/menu_paginated.sh | 610 +++++++++++++++++++-------------------- 1 file changed, 301 insertions(+), 309 deletions(-) diff --git a/lib/ui/menu_paginated.sh b/lib/ui/menu_paginated.sh index 57c2478..964255b 100755 --- a/lib/ui/menu_paginated.sh +++ b/lib/ui/menu_paginated.sh @@ -5,13 +5,13 @@ set -euo pipefail # Terminal control functions enter_alt_screen() { - if command -v tput > /dev/null 2>&1 && [[ -t 1 ]]; then - tput smcup 2> /dev/null || true + 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 + if command -v tput >/dev/null 2>&1 && [[ -t 1 ]]; then + tput rmcup 2>/dev/null || true fi } @@ -22,13 +22,13 @@ _pm_get_terminal_height() { # Try stty size first (most reliable, real-time) # Use /dev/null | awk '{print $1}') + height=$(stty size /dev/null | awk '{print $1}') fi # Fallback to tput if [[ -z "$height" || $height -le 0 ]]; then - if command -v tput > /dev/null 2>&1; then - height=$(tput lines 2> /dev/null || echo "24") + if command -v tput >/dev/null 2>&1; then + height=$(tput lines 2>/dev/null || echo "24") else height=24 fi @@ -109,7 +109,7 @@ paginated_multi_select() { has_metadata="true" fi if [[ -n "${MOLE_MENU_FILTER_NAMES:-}" ]]; then - while IFS= read -r v; do filter_names+=("$v"); done <<< "$MOLE_MENU_FILTER_NAMES" + while IFS= read -r v; do filter_names+=("$v"); done <<<"$MOLE_MENU_FILTER_NAMES" has_filter_names="true" fi @@ -138,7 +138,7 @@ paginated_multi_select() { if [[ -n "${MOLE_PRESELECTED_INDICES:-}" ]]; then local cleaned_preselect="${MOLE_PRESELECTED_INDICES//[[:space:]]/}" local -a initial_indices=() - IFS=',' read -ra initial_indices <<< "$cleaned_preselect" + 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 # Only count if not already selected (handles duplicates) @@ -152,16 +152,16 @@ paginated_multi_select() { # 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 "") + 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 + 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 + stty sane 2>/dev/null || stty echo icanon 2>/dev/null || true fi if [[ "${external_alt_screen:-false}" == false ]]; then leave_alt_screen @@ -187,7 +187,7 @@ paginated_multi_select() { trap handle_interrupt INT TERM # Setup terminal - preserve interrupt character - stty -echo -icanon intr ^C 2> /dev/null || true + 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 @@ -208,7 +208,7 @@ paginated_multi_select() { local -a segs=("$@") local cols="${COLUMNS:-}" - [[ -z "$cols" ]] && cols=$(tput cols 2> /dev/null || echo 80) + [[ -z "$cols" ]] && cols=$(tput cols 2>/dev/null || echo 80) [[ "$cols" =~ ^[0-9]+$ ]] || cols=80 _strip_ansi_len() { @@ -287,23 +287,23 @@ 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 "${active_indices[@]}"; do case "$sort_mode" in - date) k="${epochs[id]:-0}" ;; - size) k="${sizekb[id]:-0}" ;; - name | *) k="${items[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" + printf "%s\t%s\n" "$k" "$id" >>"$tmpfile" done 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) + done < <(LC_ALL=C sort -t $'\t' $sort_key -- "$tmpfile" 2>/dev/null) rm -f "$tmpfile" else @@ -432,9 +432,9 @@ paginated_multi_select() { # Build sort status local sort_label="" case "$sort_mode" in - date) sort_label="Date" ;; - name) sort_label="Name" ;; - size) sort_label="Size" ;; + date) sort_label="Date" ;; + name) sort_label="Name" ;; + size) sort_label="Size" ;; esac local sort_status="${sort_label}" @@ -469,7 +469,7 @@ paginated_multi_select() { elif [[ "$has_metadata" == "true" ]]; then # With metadata: show sort controls local term_width="${COLUMNS:-}" - [[ -z "$term_width" ]] && term_width=$(tput cols 2> /dev/null || echo 80) + [[ -z "$term_width" ]] && term_width=$(tput cols 2>/dev/null || echo 80) [[ "$term_width" =~ ^[0-9]+$ ]] || term_width=80 # Full controls @@ -527,308 +527,300 @@ paginated_multi_select() { key=$(read_key) case "$key" in - "QUIT") + "QUIT") + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + filter_text="" + unset MOLE_READ_KEY_FORCE_CHAR + rebuild_view + cursor_pos=0 + top_index=0 + need_full_redraw=true + else + cleanup + return 1 + fi + ;; + "UP") + if [[ ${#view_indices[@]} -eq 0 ]]; then + : + elif [[ $cursor_pos -gt 0 ]]; then + local old_cursor=$cursor_pos + ((cursor_pos--)) + local new_cursor=$cursor_pos + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - filter_text="" - unset MOLE_READ_KEY_FORCE_CHAR - rebuild_view - cursor_pos=0 - top_index=0 - need_full_redraw=true - else - cleanup - return 1 + draw_header fi - ;; - "UP") - if [[ ${#view_indices[@]} -eq 0 ]]; then - : - elif [[ $cursor_pos -gt 0 ]]; then - local old_cursor=$cursor_pos - ((cursor_pos--)) - local new_cursor=$cursor_pos - if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - draw_header + local old_row=$((old_cursor + 3)) + local new_row=$((new_cursor + 3)) + + printf "\033[%d;1H" "$old_row" >&2 + render_item "$old_cursor" false + printf "\033[%d;1H" "$new_row" >&2 + render_item "$new_cursor" true + + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + + prev_cursor_pos=$cursor_pos + continue + elif [[ $top_index -gt 0 ]]; then + ((top_index--)) + + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + draw_header + fi + + local start_idx=$top_index + local end_idx=$((top_index + items_per_page - 1)) + local visible_total=${#view_indices[@]} + [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) + + for ((i = start_idx; i <= end_idx; i++)); do + local row=$((i - start_idx + 3)) + printf "\033[%d;1H" "$row" >&2 + local is_current=false + [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true + render_item $((i - start_idx)) $is_current + done + + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + + prev_cursor_pos=$cursor_pos + prev_top_index=$top_index + continue + fi + ;; + "DOWN") + if [[ ${#view_indices[@]} -eq 0 ]]; then + : + else + local absolute_index=$((top_index + cursor_pos)) + local last_index=$((${#view_indices[@]} - 1)) + if [[ $absolute_index -lt $last_index ]]; then + local visible_count=$((${#view_indices[@]} - top_index)) + [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page + + if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then + local old_cursor=$cursor_pos + ((cursor_pos++)) + local new_cursor=$cursor_pos + + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + draw_header + fi + + local old_row=$((old_cursor + 3)) + local new_row=$((new_cursor + 3)) + + printf "\033[%d;1H" "$old_row" >&2 + render_item "$old_cursor" false + printf "\033[%d;1H" "$new_row" >&2 + render_item "$new_cursor" true + + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + + prev_cursor_pos=$cursor_pos + continue + elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then + ((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)) + fi + + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + draw_header + fi + + local start_idx=$top_index + local end_idx=$((top_index + items_per_page - 1)) + local visible_total=${#view_indices[@]} + [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) + + for ((i = start_idx; i <= end_idx; i++)); do + local row=$((i - start_idx + 3)) + printf "\033[%d;1H" "$row" >&2 + local is_current=false + [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true + render_item $((i - start_idx)) $is_current + done + + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + + prev_cursor_pos=$cursor_pos + prev_top_index=$top_index + continue fi + fi + fi + ;; + "SPACE") + local idx=$((top_index + cursor_pos)) + if [[ $idx -lt ${#view_indices[@]} ]]; then + local real="${view_indices[idx]}" + if [[ ${selected[real]} == true ]]; then + selected[real]=false + ((selected_count--)) + else + selected[real]=true + ((selected_count++)) + fi - local old_row=$((old_cursor + 3)) - local new_row=$((new_cursor + 3)) + # Incremental update: only redraw header (for count) and current row + # Header is at row 1 + printf "\033[1;1H\033[2K${PURPLE_BOLD}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2 - printf "\033[%d;1H" "$old_row" >&2 - render_item "$old_cursor" false - printf "\033[%d;1H" "$new_row" >&2 - render_item "$new_cursor" true + # Redraw current item row (+3: row 1=header, row 2=blank, row 3=first item) + local item_row=$((cursor_pos + 3)) + printf "\033[%d;1H" "$item_row" >&2 + render_item "$cursor_pos" true - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + # Move cursor to footer to avoid visual artifacts (items + header + 2 blanks) + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - prev_cursor_pos=$cursor_pos - continue + continue # Skip full redraw + fi + ;; + "RETRY") + # '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 + need_full_redraw=true + fi + ;; + "CHAR:s" | "CHAR:S") + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ "$has_metadata" == "true" ]]; then + case "$sort_mode" in + date) sort_mode="name" ;; + name) sort_mode="size" ;; + size) sort_mode="date" ;; + esac + rebuild_view + need_full_redraw=true + fi + ;; + "CHAR:j") + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ ${#view_indices[@]} -gt 0 ]]; then + local absolute_index=$((top_index + cursor_pos)) + local last_index=$((${#view_indices[@]} - 1)) + if [[ $absolute_index -lt $last_index ]]; then + local visible_count=$((${#view_indices[@]} - top_index)) + [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page + if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then + ((cursor_pos++)) + elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then + ((top_index++)) + fi + need_full_redraw=true + fi + fi + ;; + "CHAR:k") + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ ${#view_indices[@]} -gt 0 ]]; then + if [[ $cursor_pos -gt 0 ]]; then + ((cursor_pos--)) + need_full_redraw=true elif [[ $top_index -gt 0 ]]; then ((top_index--)) - - if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - draw_header - fi - - local start_idx=$top_index - local end_idx=$((top_index + items_per_page - 1)) - local visible_total=${#view_indices[@]} - [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) - - for ((i = start_idx; i <= end_idx; i++)); do - local row=$((i - start_idx + 3)) - printf "\033[%d;1H" "$row" >&2 - local is_current=false - [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true - render_item $((i - start_idx)) $is_current - done - - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - prev_cursor_pos=$cursor_pos - prev_top_index=$top_index - continue - fi - ;; - "DOWN") - if [[ ${#view_indices[@]} -eq 0 ]]; then - : - else - local absolute_index=$((top_index + cursor_pos)) - local last_index=$((${#view_indices[@]} - 1)) - if [[ $absolute_index -lt $last_index ]]; then - local visible_count=$((${#view_indices[@]} - top_index)) - [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page - - if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then - local old_cursor=$cursor_pos - ((cursor_pos++)) - local new_cursor=$cursor_pos - - if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - draw_header - fi - - local old_row=$((old_cursor + 3)) - local new_row=$((new_cursor + 3)) - - printf "\033[%d;1H" "$old_row" >&2 - render_item "$old_cursor" false - printf "\033[%d;1H" "$new_row" >&2 - render_item "$new_cursor" true - - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - prev_cursor_pos=$cursor_pos - continue - elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then - ((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)) - fi - - if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - draw_header - fi - - local start_idx=$top_index - local end_idx=$((top_index + items_per_page - 1)) - local visible_total=${#view_indices[@]} - [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) - - for ((i = start_idx; i <= end_idx; i++)); do - local row=$((i - start_idx + 3)) - printf "\033[%d;1H" "$row" >&2 - local is_current=false - [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true - render_item $((i - start_idx)) $is_current - done - - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - prev_cursor_pos=$cursor_pos - prev_top_index=$top_index - continue - fi - fi - fi - ;; - "SPACE") - # In filter mode with active text, treat space as search character - if [[ -n "$filter_text" ]]; then - filter_text+=" " - rebuild_view - cursor_pos=0 need_full_redraw=true - continue fi + fi + ;; + "CHAR:r" | "CHAR:R") + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + else + cleanup + return 10 + fi + ;; + "CHAR:o" | "CHAR:O") + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ "$has_metadata" == "true" ]]; then + if [[ "$sort_reverse" == "true" ]]; then + sort_reverse="false" + else + sort_reverse="true" + fi + rebuild_view + need_full_redraw=true + fi + ;; + "CHAR:/" | "CHAR:?") + export MOLE_READ_KEY_FORCE_CHAR=1 + need_full_redraw=true + ;; + "DELETE") + if [[ -n "$filter_text" ]]; then + filter_text="${filter_text%?}" + if [[ -z "$filter_text" ]]; then + unset MOLE_READ_KEY_FORCE_CHAR + fi + rebuild_view + cursor_pos=0 + top_index=0 + need_full_redraw=true + fi + ;; + "CHAR:"*) + handle_filter_char "${key#CHAR:}" || true + ;; + "ENTER") + # 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]}" - if [[ ${selected[real]} == true ]]; then - selected[real]=false - ((selected_count--)) - else - selected[real]=true - ((selected_count++)) - fi + selected[real]=true + ((selected_count++)) + fi + fi - # Incremental update: only redraw header (for count) and current row - # Header is at row 1 - printf "\033[1;1H\033[2K${PURPLE_BOLD}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2 + # 3. Confirm and exit with current selections + local -a selected_indices=() + for ((i = 0; i < total_items; i++)); do + if [[ ${selected[i]} == true ]]; then + selected_indices+=("$i") + fi + done - # Redraw current item row (+3: row 1=header, row 2=blank, row 3=first item) - local item_row=$((cursor_pos + 3)) - printf "\033[%d;1H" "$item_row" >&2 - render_item "$cursor_pos" true + local final_result="" + if [[ ${#selected_indices[@]} -gt 0 ]]; then + local IFS=',' + final_result="${selected_indices[*]}" + fi - # Move cursor to footer to avoid visual artifacts (items + header + 2 blanks) - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - continue # Skip full redraw - fi - ;; - "RETRY") - # '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 - need_full_redraw=true - fi - ;; - "CHAR:s" | "CHAR:S") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - elif [[ "$has_metadata" == "true" ]]; then - case "$sort_mode" in - date) sort_mode="name" ;; - name) sort_mode="size" ;; - size) sort_mode="date" ;; - esac - rebuild_view - need_full_redraw=true - fi - ;; - "CHAR:j") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - elif [[ ${#view_indices[@]} -gt 0 ]]; then - local absolute_index=$((top_index + cursor_pos)) - local last_index=$((${#view_indices[@]} - 1)) - if [[ $absolute_index -lt $last_index ]]; then - local visible_count=$((${#view_indices[@]} - top_index)) - [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page - if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then - ((cursor_pos++)) - elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then - ((top_index++)) - fi - need_full_redraw=true - fi - fi - ;; - "CHAR:k") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - elif [[ ${#view_indices[@]} -gt 0 ]]; then - if [[ $cursor_pos -gt 0 ]]; then - ((cursor_pos--)) - need_full_redraw=true - elif [[ $top_index -gt 0 ]]; then - ((top_index--)) - need_full_redraw=true - fi - fi - ;; - "CHAR:r" | "CHAR:R") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - else - cleanup - return 10 - fi - ;; - "CHAR:o" | "CHAR:O") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - elif [[ "$has_metadata" == "true" ]]; then - if [[ "$sort_reverse" == "true" ]]; then - sort_reverse="false" - else - sort_reverse="true" - fi - rebuild_view - need_full_redraw=true - fi - ;; - "CHAR:/" | "CHAR:?") - export MOLE_READ_KEY_FORCE_CHAR=1 - need_full_redraw=true - ;; - "DELETE") - if [[ -n "$filter_text" ]]; then - filter_text="${filter_text%?}" - if [[ -z "$filter_text" ]]; then - unset MOLE_READ_KEY_FORCE_CHAR - fi - rebuild_view - cursor_pos=0 - top_index=0 - need_full_redraw=true - fi - ;; - "CHAR:"*) - handle_filter_char "${key#CHAR:}" || true - ;; - "ENTER") - # 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 - ((selected_count++)) - 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 - selected_indices+=("$i") - fi - done - - local final_result="" - if [[ ${#selected_indices[@]} -gt 0 ]]; then - local IFS=',' - final_result="${selected_indices[*]}" - fi - - trap - EXIT INT TERM - MOLE_SELECTION_RESULT="$final_result" - export MOLE_MENU_SORT_MODE="$sort_mode" - export MOLE_MENU_SORT_REVERSE="$sort_reverse" - restore_terminal - return 0 - ;; + trap - EXIT INT TERM + MOLE_SELECTION_RESULT="$final_result" + export MOLE_MENU_SORT_MODE="$sort_mode" + export MOLE_MENU_SORT_REVERSE="$sort_reverse" + restore_terminal + return 0 + ;; esac # Drain any accumulated input after processing (e.g., mouse wheel events) From d2de32f2fea80b625263367d04609cf90ae8570e Mon Sep 17 00:00:00 2001 From: Tw93 Date: Thu, 5 Feb 2026 11:52:43 +0000 Subject: [PATCH 52/72] chore: auto format code --- lib/ui/menu_paginated.sh | 610 +++++++++++++++++++-------------------- 1 file changed, 305 insertions(+), 305 deletions(-) diff --git a/lib/ui/menu_paginated.sh b/lib/ui/menu_paginated.sh index 964255b..05316cd 100755 --- a/lib/ui/menu_paginated.sh +++ b/lib/ui/menu_paginated.sh @@ -5,13 +5,13 @@ set -euo pipefail # Terminal control functions enter_alt_screen() { - if command -v tput >/dev/null 2>&1 && [[ -t 1 ]]; then - tput smcup 2>/dev/null || true + 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 + if command -v tput > /dev/null 2>&1 && [[ -t 1 ]]; then + tput rmcup 2> /dev/null || true fi } @@ -22,13 +22,13 @@ _pm_get_terminal_height() { # Try stty size first (most reliable, real-time) # Use /dev/null | awk '{print $1}') + height=$(stty size < /dev/tty 2> /dev/null | awk '{print $1}') fi # Fallback to tput if [[ -z "$height" || $height -le 0 ]]; then - if command -v tput >/dev/null 2>&1; then - height=$(tput lines 2>/dev/null || echo "24") + if command -v tput > /dev/null 2>&1; then + height=$(tput lines 2> /dev/null || echo "24") else height=24 fi @@ -109,7 +109,7 @@ paginated_multi_select() { has_metadata="true" fi if [[ -n "${MOLE_MENU_FILTER_NAMES:-}" ]]; then - while IFS= read -r v; do filter_names+=("$v"); done <<<"$MOLE_MENU_FILTER_NAMES" + while IFS= read -r v; do filter_names+=("$v"); done <<< "$MOLE_MENU_FILTER_NAMES" has_filter_names="true" fi @@ -138,7 +138,7 @@ paginated_multi_select() { if [[ -n "${MOLE_PRESELECTED_INDICES:-}" ]]; then local cleaned_preselect="${MOLE_PRESELECTED_INDICES//[[:space:]]/}" local -a initial_indices=() - IFS=',' read -ra initial_indices <<<"$cleaned_preselect" + 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 # Only count if not already selected (handles duplicates) @@ -152,16 +152,16 @@ paginated_multi_select() { # 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 "") + 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 + 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 + stty sane 2> /dev/null || stty echo icanon 2> /dev/null || true fi if [[ "${external_alt_screen:-false}" == false ]]; then leave_alt_screen @@ -187,7 +187,7 @@ paginated_multi_select() { trap handle_interrupt INT TERM # Setup terminal - preserve interrupt character - stty -echo -icanon intr ^C 2>/dev/null || true + 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 @@ -208,7 +208,7 @@ paginated_multi_select() { local -a segs=("$@") local cols="${COLUMNS:-}" - [[ -z "$cols" ]] && cols=$(tput cols 2>/dev/null || echo 80) + [[ -z "$cols" ]] && cols=$(tput cols 2> /dev/null || echo 80) [[ "$cols" =~ ^[0-9]+$ ]] || cols=80 _strip_ansi_len() { @@ -287,23 +287,23 @@ 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 "${active_indices[@]}"; do case "$sort_mode" in - date) k="${epochs[id]:-0}" ;; - size) k="${sizekb[id]:-0}" ;; - name | *) k="${items[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" + printf "%s\t%s\n" "$k" "$id" >> "$tmpfile" done 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) + done < <(LC_ALL=C sort -t $'\t' $sort_key -- "$tmpfile" 2> /dev/null) rm -f "$tmpfile" else @@ -432,9 +432,9 @@ paginated_multi_select() { # Build sort status local sort_label="" case "$sort_mode" in - date) sort_label="Date" ;; - name) sort_label="Name" ;; - size) sort_label="Size" ;; + date) sort_label="Date" ;; + name) sort_label="Name" ;; + size) sort_label="Size" ;; esac local sort_status="${sort_label}" @@ -469,7 +469,7 @@ paginated_multi_select() { elif [[ "$has_metadata" == "true" ]]; then # With metadata: show sort controls local term_width="${COLUMNS:-}" - [[ -z "$term_width" ]] && term_width=$(tput cols 2>/dev/null || echo 80) + [[ -z "$term_width" ]] && term_width=$(tput cols 2> /dev/null || echo 80) [[ "$term_width" =~ ^[0-9]+$ ]] || term_width=80 # Full controls @@ -527,300 +527,300 @@ paginated_multi_select() { key=$(read_key) case "$key" in - "QUIT") - if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - filter_text="" - unset MOLE_READ_KEY_FORCE_CHAR - rebuild_view - cursor_pos=0 - top_index=0 - need_full_redraw=true - else - cleanup - return 1 - fi - ;; - "UP") - if [[ ${#view_indices[@]} -eq 0 ]]; then - : - elif [[ $cursor_pos -gt 0 ]]; then - local old_cursor=$cursor_pos - ((cursor_pos--)) - local new_cursor=$cursor_pos - + "QUIT") if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - draw_header - fi - - local old_row=$((old_cursor + 3)) - local new_row=$((new_cursor + 3)) - - printf "\033[%d;1H" "$old_row" >&2 - render_item "$old_cursor" false - printf "\033[%d;1H" "$new_row" >&2 - render_item "$new_cursor" true - - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - prev_cursor_pos=$cursor_pos - continue - elif [[ $top_index -gt 0 ]]; then - ((top_index--)) - - if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - draw_header - fi - - local start_idx=$top_index - local end_idx=$((top_index + items_per_page - 1)) - local visible_total=${#view_indices[@]} - [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) - - for ((i = start_idx; i <= end_idx; i++)); do - local row=$((i - start_idx + 3)) - printf "\033[%d;1H" "$row" >&2 - local is_current=false - [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true - render_item $((i - start_idx)) $is_current - done - - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - prev_cursor_pos=$cursor_pos - prev_top_index=$top_index - continue - fi - ;; - "DOWN") - if [[ ${#view_indices[@]} -eq 0 ]]; then - : - else - local absolute_index=$((top_index + cursor_pos)) - local last_index=$((${#view_indices[@]} - 1)) - if [[ $absolute_index -lt $last_index ]]; then - local visible_count=$((${#view_indices[@]} - top_index)) - [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page - - if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then - local old_cursor=$cursor_pos - ((cursor_pos++)) - local new_cursor=$cursor_pos - - if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - draw_header - fi - - local old_row=$((old_cursor + 3)) - local new_row=$((new_cursor + 3)) - - printf "\033[%d;1H" "$old_row" >&2 - render_item "$old_cursor" false - printf "\033[%d;1H" "$new_row" >&2 - render_item "$new_cursor" true - - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - prev_cursor_pos=$cursor_pos - continue - elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then - ((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)) - fi - - if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - draw_header - fi - - local start_idx=$top_index - local end_idx=$((top_index + items_per_page - 1)) - local visible_total=${#view_indices[@]} - [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) - - for ((i = start_idx; i <= end_idx; i++)); do - local row=$((i - start_idx + 3)) - printf "\033[%d;1H" "$row" >&2 - local is_current=false - [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true - render_item $((i - start_idx)) $is_current - done - - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - prev_cursor_pos=$cursor_pos - prev_top_index=$top_index - continue - fi - fi - fi - ;; - "SPACE") - local idx=$((top_index + cursor_pos)) - if [[ $idx -lt ${#view_indices[@]} ]]; then - local real="${view_indices[idx]}" - if [[ ${selected[real]} == true ]]; then - selected[real]=false - ((selected_count--)) - else - selected[real]=true - ((selected_count++)) - fi - - # Incremental update: only redraw header (for count) and current row - # Header is at row 1 - printf "\033[1;1H\033[2K${PURPLE_BOLD}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2 - - # Redraw current item row (+3: row 1=header, row 2=blank, row 3=first item) - local item_row=$((cursor_pos + 3)) - printf "\033[%d;1H" "$item_row" >&2 - render_item "$cursor_pos" true - - # Move cursor to footer to avoid visual artifacts (items + header + 2 blanks) - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - continue # Skip full redraw - fi - ;; - "RETRY") - # '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 - need_full_redraw=true - fi - ;; - "CHAR:s" | "CHAR:S") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - elif [[ "$has_metadata" == "true" ]]; then - case "$sort_mode" in - date) sort_mode="name" ;; - name) sort_mode="size" ;; - size) sort_mode="date" ;; - esac - rebuild_view - need_full_redraw=true - fi - ;; - "CHAR:j") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - elif [[ ${#view_indices[@]} -gt 0 ]]; then - local absolute_index=$((top_index + cursor_pos)) - local last_index=$((${#view_indices[@]} - 1)) - if [[ $absolute_index -lt $last_index ]]; then - local visible_count=$((${#view_indices[@]} - top_index)) - [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page - if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then - ((cursor_pos++)) - elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then - ((top_index++)) - fi + filter_text="" + unset MOLE_READ_KEY_FORCE_CHAR + rebuild_view + cursor_pos=0 + top_index=0 need_full_redraw=true + else + cleanup + return 1 fi - fi - ;; - "CHAR:k") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - elif [[ ${#view_indices[@]} -gt 0 ]]; then - if [[ $cursor_pos -gt 0 ]]; then + ;; + "UP") + if [[ ${#view_indices[@]} -eq 0 ]]; then + : + elif [[ $cursor_pos -gt 0 ]]; then + local old_cursor=$cursor_pos ((cursor_pos--)) - need_full_redraw=true + local new_cursor=$cursor_pos + + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + draw_header + fi + + local old_row=$((old_cursor + 3)) + local new_row=$((new_cursor + 3)) + + printf "\033[%d;1H" "$old_row" >&2 + render_item "$old_cursor" false + printf "\033[%d;1H" "$new_row" >&2 + render_item "$new_cursor" true + + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + + prev_cursor_pos=$cursor_pos + continue elif [[ $top_index -gt 0 ]]; then ((top_index--)) - need_full_redraw=true - fi - fi - ;; - "CHAR:r" | "CHAR:R") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - else - cleanup - return 10 - fi - ;; - "CHAR:o" | "CHAR:O") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - elif [[ "$has_metadata" == "true" ]]; then - if [[ "$sort_reverse" == "true" ]]; then - sort_reverse="false" - else - sort_reverse="true" - fi - rebuild_view - need_full_redraw=true - fi - ;; - "CHAR:/" | "CHAR:?") - export MOLE_READ_KEY_FORCE_CHAR=1 - need_full_redraw=true - ;; - "DELETE") - if [[ -n "$filter_text" ]]; then - filter_text="${filter_text%?}" - if [[ -z "$filter_text" ]]; then - unset MOLE_READ_KEY_FORCE_CHAR - fi - rebuild_view - cursor_pos=0 - top_index=0 - need_full_redraw=true - fi - ;; - "CHAR:"*) - handle_filter_char "${key#CHAR:}" || true - ;; - "ENTER") - # 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 + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + draw_header + fi + + local start_idx=$top_index + local end_idx=$((top_index + items_per_page - 1)) + local visible_total=${#view_indices[@]} + [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) + + for ((i = start_idx; i <= end_idx; i++)); do + local row=$((i - start_idx + 3)) + printf "\033[%d;1H" "$row" >&2 + local is_current=false + [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true + render_item $((i - start_idx)) $is_current + done + + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + + prev_cursor_pos=$cursor_pos + prev_top_index=$top_index + continue + fi + ;; + "DOWN") + if [[ ${#view_indices[@]} -eq 0 ]]; then + : + else + local absolute_index=$((top_index + cursor_pos)) + local last_index=$((${#view_indices[@]} - 1)) + if [[ $absolute_index -lt $last_index ]]; then + local visible_count=$((${#view_indices[@]} - top_index)) + [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page + + if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then + local old_cursor=$cursor_pos + ((cursor_pos++)) + local new_cursor=$cursor_pos + + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + draw_header + fi + + local old_row=$((old_cursor + 3)) + local new_row=$((new_cursor + 3)) + + printf "\033[%d;1H" "$old_row" >&2 + render_item "$old_cursor" false + printf "\033[%d;1H" "$new_row" >&2 + render_item "$new_cursor" true + + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + + prev_cursor_pos=$cursor_pos + continue + elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then + ((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)) + fi + + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + draw_header + fi + + local start_idx=$top_index + local end_idx=$((top_index + items_per_page - 1)) + local visible_total=${#view_indices[@]} + [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) + + for ((i = start_idx; i <= end_idx; i++)); do + local row=$((i - start_idx + 3)) + printf "\033[%d;1H" "$row" >&2 + local is_current=false + [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true + render_item $((i - start_idx)) $is_current + done + + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + + prev_cursor_pos=$cursor_pos + prev_top_index=$top_index + continue + fi + fi + fi + ;; + "SPACE") local idx=$((top_index + cursor_pos)) if [[ $idx -lt ${#view_indices[@]} ]]; then local real="${view_indices[idx]}" - selected[real]=true - ((selected_count++)) + if [[ ${selected[real]} == true ]]; then + selected[real]=false + ((selected_count--)) + else + selected[real]=true + ((selected_count++)) + fi + + # Incremental update: only redraw header (for count) and current row + # Header is at row 1 + printf "\033[1;1H\033[2K${PURPLE_BOLD}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2 + + # Redraw current item row (+3: row 1=header, row 2=blank, row 3=first item) + local item_row=$((cursor_pos + 3)) + printf "\033[%d;1H" "$item_row" >&2 + render_item "$cursor_pos" true + + # Move cursor to footer to avoid visual artifacts (items + header + 2 blanks) + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + + continue # Skip full redraw 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 - selected_indices+=("$i") + ;; + "RETRY") + # '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 + need_full_redraw=true fi - done + ;; + "CHAR:s" | "CHAR:S") + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ "$has_metadata" == "true" ]]; then + case "$sort_mode" in + date) sort_mode="name" ;; + name) sort_mode="size" ;; + size) sort_mode="date" ;; + esac + rebuild_view + need_full_redraw=true + fi + ;; + "CHAR:j") + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ ${#view_indices[@]} -gt 0 ]]; then + local absolute_index=$((top_index + cursor_pos)) + local last_index=$((${#view_indices[@]} - 1)) + if [[ $absolute_index -lt $last_index ]]; then + local visible_count=$((${#view_indices[@]} - top_index)) + [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page + if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then + ((cursor_pos++)) + elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then + ((top_index++)) + fi + need_full_redraw=true + fi + fi + ;; + "CHAR:k") + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ ${#view_indices[@]} -gt 0 ]]; then + if [[ $cursor_pos -gt 0 ]]; then + ((cursor_pos--)) + need_full_redraw=true + elif [[ $top_index -gt 0 ]]; then + ((top_index--)) + need_full_redraw=true + fi + fi + ;; + "CHAR:r" | "CHAR:R") + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + else + cleanup + return 10 + fi + ;; + "CHAR:o" | "CHAR:O") + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ "$has_metadata" == "true" ]]; then + if [[ "$sort_reverse" == "true" ]]; then + sort_reverse="false" + else + sort_reverse="true" + fi + rebuild_view + need_full_redraw=true + fi + ;; + "CHAR:/" | "CHAR:?") + export MOLE_READ_KEY_FORCE_CHAR=1 + need_full_redraw=true + ;; + "DELETE") + if [[ -n "$filter_text" ]]; then + filter_text="${filter_text%?}" + if [[ -z "$filter_text" ]]; then + unset MOLE_READ_KEY_FORCE_CHAR + fi + rebuild_view + cursor_pos=0 + top_index=0 + need_full_redraw=true + fi + ;; + "CHAR:"*) + handle_filter_char "${key#CHAR:}" || true + ;; + "ENTER") + # 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 - local final_result="" - if [[ ${#selected_indices[@]} -gt 0 ]]; then - local IFS=',' - final_result="${selected_indices[*]}" - fi + # 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 + ((selected_count++)) + fi + fi - trap - EXIT INT TERM - MOLE_SELECTION_RESULT="$final_result" - export MOLE_MENU_SORT_MODE="$sort_mode" - export MOLE_MENU_SORT_REVERSE="$sort_reverse" - restore_terminal - return 0 - ;; + # 3. Confirm and exit with current selections + local -a selected_indices=() + for ((i = 0; i < total_items; i++)); do + if [[ ${selected[i]} == true ]]; then + selected_indices+=("$i") + fi + done + + local final_result="" + if [[ ${#selected_indices[@]} -gt 0 ]]; then + local IFS=',' + final_result="${selected_indices[*]}" + fi + + trap - EXIT INT TERM + MOLE_SELECTION_RESULT="$final_result" + export MOLE_MENU_SORT_MODE="$sort_mode" + export MOLE_MENU_SORT_REVERSE="$sort_reverse" + restore_terminal + return 0 + ;; esac # Drain any accumulated input after processing (e.g., mouse wheel events) From 337f526021500ea32ca9886f4a354e0599140306 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 7 Feb 2026 10:23:34 +0800 Subject: [PATCH 53/72] fix(uninstall): fix error in batch uninstallation logic --- lib/uninstall/batch.sh | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index 9d03af9..851436d 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -717,42 +717,6 @@ batch_uninstall_applications() { # Disable uninstall mode unset MOLE_UNINSTALL_MODE - if [[ $success_count -gt 0 ]]; then - local cache_file="$HOME/.cache/mole/app_scan_cache" - if [[ -f "$cache_file" ]]; then - if [[ ${#success_items[@]} -gt 0 ]]; then - local -a removed_paths=("${success_items[@]}") - local temp_cache - temp_cache=$(create_temp_file) - local line_removed=false - while IFS='|' read -r epoch path rest; do - local keep_line=true - for removed_path in "${removed_paths[@]}"; do - if [[ "$path" == "$removed_path" ]]; then - keep_line=false - line_removed=true - break - fi - done - if [[ $keep_line == true && -n "$path" ]]; then - echo "${epoch}|${path}|${rest}" - fi - done < "$cache_file" > "$temp_cache" - - if [[ $line_removed == true ]]; then - if [[ -s "$temp_cache" ]]; then - mv "$temp_cache" "$cache_file" 2> /dev/null || rm -f "$temp_cache" - else - # All apps removed, delete cache to force rescan - rm -f "$cache_file" "$temp_cache" - fi - else - rm -f "$temp_cache" - fi - fi - fi - fi - _restore_uninstall_traps unset -f _restore_uninstall_traps From f3b288a21b264e278a9a659c849262c8186f53c1 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 7 Feb 2026 10:23:35 +0800 Subject: [PATCH 54/72] feat(uninstall): implement metadata caching for faster app scanning --- bin/uninstall.sh | 683 +++++++++++++++++++++++++++++++---------------- 1 file changed, 459 insertions(+), 224 deletions(-) diff --git a/bin/uninstall.sh b/bin/uninstall.sh index 437f66d..8b9fcf9 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -5,6 +5,10 @@ set -euo pipefail +# Preserve user's locale for app display name lookup. +readonly MOLE_UNINSTALL_USER_LC_ALL="${LC_ALL:-}" +readonly MOLE_UNINSTALL_USER_LANG="${LANG:-}" + # Fix locale issues on non-English systems. export LC_ALL=C export LANG=C @@ -27,60 +31,341 @@ total_items=0 files_cleaned=0 total_size_cleaned=0 -# Scan applications and collect information. -scan_applications() { - # Cache app scan (24h TTL). - local cache_dir="$HOME/.cache/mole" - local cache_file="$cache_dir/app_scan_cache" - local cache_ttl=86400 # 24 hours - local force_rescan="${1:-false}" +readonly MOLE_UNINSTALL_META_CACHE_DIR="$HOME/.cache/mole" +readonly MOLE_UNINSTALL_META_CACHE_FILE="$MOLE_UNINSTALL_META_CACHE_DIR/uninstall_app_metadata_v1" +readonly MOLE_UNINSTALL_META_CACHE_LOCK="${MOLE_UNINSTALL_META_CACHE_FILE}.lock" +readonly MOLE_UNINSTALL_META_REFRESH_TTL=604800 # 7 days +readonly MOLE_UNINSTALL_SCAN_SPINNER_DELAY_SEC="0.15" - ensure_user_dir "$cache_dir" +uninstall_relative_time_from_epoch() { + local value_epoch="${1:-0}" + local now_epoch="${2:-0}" - if [[ $force_rescan == false && -f "$cache_file" ]]; then - local cache_age=$(($(get_epoch_seconds) - $(get_file_mtime "$cache_file"))) - [[ $cache_age -eq $(get_epoch_seconds) ]] && cache_age=86401 + if [[ ! "$value_epoch" =~ ^[0-9]+$ || $value_epoch -le 0 ]]; then + echo "..." + return 0 + fi - if [[ $cache_age -lt $cache_ttl ]]; then - if [[ -t 2 ]]; then - echo -e "${GREEN}Loading from cache...${NC}" >&2 - sleep 0.3 - fi - echo "$cache_file" - return 0 + local days_ago=$(((now_epoch - value_epoch) / 86400)) + if [[ $days_ago -lt 0 ]]; then + days_ago=0 + fi + + if [[ $days_ago -eq 0 ]]; then + echo "Today" + elif [[ $days_ago -eq 1 ]]; then + echo "Yesterday" + elif [[ $days_ago -lt 7 ]]; then + echo "${days_ago} days ago" + elif [[ $days_ago -lt 30 ]]; then + local weeks_ago=$((days_ago / 7)) + [[ $weeks_ago -eq 1 ]] && echo "1 week ago" || echo "${weeks_ago} weeks ago" + elif [[ $days_ago -lt 365 ]]; then + local months_ago=$((days_ago / 30)) + [[ $months_ago -eq 1 ]] && echo "1 month ago" || echo "${months_ago} months ago" + else + local years_ago=$((days_ago / 365)) + [[ $years_ago -eq 1 ]] && echo "1 year ago" || echo "${years_ago} years ago" + fi +} + +uninstall_resolve_display_name() { + local app_path="$1" + local app_name="$2" + local display_name="$app_name" + + if [[ -f "$app_path/Contents/Info.plist" ]]; then + local md_display_name + if [[ -n "$MOLE_UNINSTALL_USER_LC_ALL" ]]; then + md_display_name=$(run_with_timeout 0.04 env LC_ALL="$MOLE_UNINSTALL_USER_LC_ALL" LANG="$MOLE_UNINSTALL_USER_LANG" mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "") + elif [[ -n "$MOLE_UNINSTALL_USER_LANG" ]]; then + md_display_name=$(run_with_timeout 0.04 env LANG="$MOLE_UNINSTALL_USER_LANG" mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "") + else + md_display_name=$(run_with_timeout 0.04 mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "") + fi + + local bundle_display_name + bundle_display_name=$(plutil -extract CFBundleDisplayName raw "$app_path/Contents/Info.plist" 2> /dev/null || echo "") + local bundle_name + bundle_name=$(plutil -extract CFBundleName raw "$app_path/Contents/Info.plist" 2> /dev/null || echo "") + + if [[ "$md_display_name" == /* ]]; then + md_display_name="" + fi + md_display_name="${md_display_name//|/-}" + md_display_name="${md_display_name//[$'\t\r\n']/}" + + bundle_display_name="${bundle_display_name//|/-}" + bundle_display_name="${bundle_display_name//[$'\t\r\n']/}" + + bundle_name="${bundle_name//|/-}" + bundle_name="${bundle_name//[$'\t\r\n']/}" + + if [[ -n "$md_display_name" && "$md_display_name" != "(null)" && "$md_display_name" != "$app_name" ]]; then + display_name="$md_display_name" + elif [[ -n "$bundle_display_name" && "$bundle_display_name" != "(null)" ]]; then + display_name="$bundle_display_name" + elif [[ -n "$bundle_name" && "$bundle_name" != "(null)" ]]; then + display_name="$bundle_name" fi fi - local inline_loading=false - if [[ -t 1 && -t 2 ]]; then - inline_loading=true - printf "\033[2J\033[H" >&2 # Clear screen for inline loading + if [[ "$display_name" == /* ]]; then + display_name="$app_name" + fi + display_name="${display_name%.app}" + display_name="${display_name//|/-}" + display_name="${display_name//[$'\t\r\n']/}" + echo "$display_name" +} + +uninstall_acquire_metadata_lock() { + local lock_dir="$1" + local attempts=0 + + while ! mkdir "$lock_dir" 2> /dev/null; do + ((attempts++)) + if [[ $attempts -ge 40 ]]; then + return 1 + fi + + # Clean stale lock if older than 5 minutes. + if [[ -d "$lock_dir" ]]; then + local lock_mtime + lock_mtime=$(get_file_mtime "$lock_dir") + # Skip stale detection if mtime lookup failed (returns 0). + if [[ "$lock_mtime" =~ ^[0-9]+$ && $lock_mtime -gt 0 ]]; then + local lock_age + lock_age=$(($(get_epoch_seconds) - lock_mtime)) + if [[ "$lock_age" =~ ^-?[0-9]+$ && $lock_age -gt 300 ]]; then + rmdir "$lock_dir" 2> /dev/null || true + fi + fi + fi + + sleep 0.1 2> /dev/null || sleep 1 + done + + return 0 +} + +uninstall_release_metadata_lock() { + local lock_dir="$1" + [[ -d "$lock_dir" ]] && rmdir "$lock_dir" 2> /dev/null || true +} + +start_uninstall_metadata_refresh() { + local refresh_file="$1" + [[ ! -s "$refresh_file" ]] && { + rm -f "$refresh_file" 2> /dev/null || true + return 0 + } + + ( + _refresh_debug() { + if [[ "${MO_DEBUG:-}" == "1" ]]; then + local ts + ts=$(date "+%Y-%m-%d %H:%M:%S" 2> /dev/null || echo "?") + echo "[$ts] DEBUG: [metadata-refresh] $*" >> "${HOME}/.config/mole/mole_debug_session.log" 2> /dev/null || true + fi + } + + ensure_user_dir "$MOLE_UNINSTALL_META_CACHE_DIR" + ensure_user_file "$MOLE_UNINSTALL_META_CACHE_FILE" + if [[ ! -r "$MOLE_UNINSTALL_META_CACHE_FILE" ]]; then + if ! : > "$MOLE_UNINSTALL_META_CACHE_FILE" 2> /dev/null; then + _refresh_debug "Cannot create cache file, aborting" + exit 0 + fi + fi + if [[ ! -w "$MOLE_UNINSTALL_META_CACHE_FILE" ]]; then + _refresh_debug "Cache file not writable, aborting" + exit 0 + fi + + local updates_file + updates_file=$(mktemp 2> /dev/null) || { + _refresh_debug "mktemp failed, aborting" + exit 0 + } + local now_epoch + now_epoch=$(get_epoch_seconds) + local max_parallel + max_parallel=$(get_optimal_parallel_jobs "io") + if [[ ! "$max_parallel" =~ ^[0-9]+$ || $max_parallel -lt 1 ]]; then + max_parallel=1 + elif [[ $max_parallel -gt 4 ]]; then + max_parallel=4 + fi + local -a worker_pids=() + local worker_idx=0 + + while IFS='|' read -r app_path app_mtime bundle_id display_name; do + [[ -n "$app_path" && -d "$app_path" ]] || continue + ((worker_idx++)) + local worker_output="${updates_file}.${worker_idx}" + + ( + local last_used_epoch=0 + local metadata_date + metadata_date=$(run_with_timeout 0.2 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "") + if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then + last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0") + fi + + if [[ ! "$last_used_epoch" =~ ^[0-9]+$ || $last_used_epoch -le 0 ]]; then + last_used_epoch=0 + fi + + local size_kb + size_kb=$(get_path_size_kb "$app_path") + [[ "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0 + + printf "%s|%s|%s|%s|%s|%s|%s\n" "$app_path" "${app_mtime:-0}" "$size_kb" "${last_used_epoch:-0}" "$now_epoch" "$bundle_id" "$display_name" > "$worker_output" + ) & + worker_pids+=($!) + + if ((${#worker_pids[@]} >= max_parallel)); then + wait "${worker_pids[0]}" 2> /dev/null || true + worker_pids=("${worker_pids[@]:1}") + fi + done < "$refresh_file" + + local worker_pid + for worker_pid in "${worker_pids[@]}"; do + wait "$worker_pid" 2> /dev/null || true + done + + local worker_output + for worker_output in "${updates_file}".*; do + [[ -f "$worker_output" ]] || continue + cat "$worker_output" >> "$updates_file" + rm -f "$worker_output" + done + + if [[ ! -s "$updates_file" ]]; then + rm -f "$updates_file" + exit 0 + fi + + if ! uninstall_acquire_metadata_lock "$MOLE_UNINSTALL_META_CACHE_LOCK"; then + _refresh_debug "Failed to acquire lock, aborting merge" + rm -f "$updates_file" + exit 0 + fi + + local merged_file + merged_file=$(mktemp 2> /dev/null) || { + _refresh_debug "mktemp for merge failed, aborting" + uninstall_release_metadata_lock "$MOLE_UNINSTALL_META_CACHE_LOCK" + rm -f "$updates_file" + exit 0 + } + + awk -F'|' ' + NR == FNR { updates[$1] = $0; next } + !($1 in updates) { print } + END { + for (path in updates) { + print updates[path] + } + } + ' "$updates_file" "$MOLE_UNINSTALL_META_CACHE_FILE" > "$merged_file" + + mv "$merged_file" "$MOLE_UNINSTALL_META_CACHE_FILE" 2> /dev/null || { + cp "$merged_file" "$MOLE_UNINSTALL_META_CACHE_FILE" 2> /dev/null || true + rm -f "$merged_file" + } + + uninstall_release_metadata_lock "$MOLE_UNINSTALL_META_CACHE_LOCK" + rm -f "$updates_file" + rm -f "$refresh_file" 2> /dev/null || true + ) > /dev/null 2>&1 & + +} + +# Scan applications and collect information. +scan_applications() { + local temp_file scan_raw_file merged_file refresh_file cache_snapshot_file + temp_file=$(create_temp_file) + scan_raw_file="${temp_file}.scan" + merged_file="${temp_file}.merged" + refresh_file="${temp_file}.refresh" + cache_snapshot_file="${temp_file}.cache" + : > "$scan_raw_file" + : > "$refresh_file" + : > "$cache_snapshot_file" + + ensure_user_dir "$MOLE_UNINSTALL_META_CACHE_DIR" + ensure_user_file "$MOLE_UNINSTALL_META_CACHE_FILE" + local cache_source="$MOLE_UNINSTALL_META_CACHE_FILE" + local cache_source_is_temp=false + if [[ ! -r "$cache_source" ]]; then + cache_source=$(create_temp_file) + : > "$cache_source" + cache_source_is_temp=true fi - local temp_file - temp_file=$(create_temp_file) + # Fast lookup cache for unchanged apps: path+mtime -> bundle_id/display_name. + local -a cache_paths=() + local -a cache_mtimes=() + local -a cache_bundle_ids=() + local -a cache_display_names=() + local cache_path cache_mtime _cache_size _cache_epoch _cache_updated cache_bundle cache_display + while IFS='|' read -r cache_path cache_mtime _cache_size _cache_epoch _cache_updated cache_bundle cache_display; do + [[ -n "$cache_path" ]] || continue + cache_paths+=("$cache_path") + cache_mtimes+=("${cache_mtime:-0}") + cache_bundle_ids+=("${cache_bundle:-}") + cache_display_names+=("${cache_display:-}") + done < "$cache_source" + + lookup_cached_identity() { + local target_path="$1" + local target_mtime="$2" + local idx + for ((idx = 0; idx < ${#cache_paths[@]}; idx++)); do + if [[ "${cache_paths[idx]}" == "$target_path" ]]; then + if [[ "${cache_mtimes[idx]:-0}" == "${target_mtime:-0}" ]]; then + echo "${cache_bundle_ids[idx]:-}|${cache_display_names[idx]:-}" + else + echo "|" + fi + return 0 + fi + done + echo "|" + } # Local spinner_pid for cleanup local spinner_pid="" + local spinner_shown_file="${temp_file}.spinner_shown" + local previous_int_trap="" + previous_int_trap=$(trap -p INT || true) + + restore_scan_int_trap() { + if [[ -n "$previous_int_trap" ]]; then + eval "$previous_int_trap" + else + trap - INT + fi + } # Trap to handle Ctrl+C during scan - local scan_interrupted=false # shellcheck disable=SC2329 # Function invoked indirectly via trap trap_scan_cleanup() { - scan_interrupted=true if [[ -n "$spinner_pid" ]]; then kill -TERM "$spinner_pid" 2> /dev/null || true wait "$spinner_pid" 2> /dev/null || true fi - printf "\r\033[K" >&2 - rm -f "$temp_file" "${temp_file}.sorted" "${temp_file}.progress" 2> /dev/null || true + if [[ -f "$spinner_shown_file" ]]; then + printf "\r\033[K" >&2 + fi + rm -f "$temp_file" "$scan_raw_file" "$merged_file" "$refresh_file" "$cache_snapshot_file" "${temp_file}.sorted" "${temp_file}.progress" "$spinner_shown_file" 2> /dev/null || true exit 130 } trap trap_scan_cleanup INT - local current_epoch - current_epoch=$(get_epoch_seconds) - # Pass 1: collect app paths and bundle IDs (no mdls). local -a app_data_tuples=() local -a app_dirs=( @@ -141,27 +426,27 @@ scan_applications() { fi fi - local bundle_id="unknown" - if [[ -f "$app_path/Contents/Info.plist" ]]; then - bundle_id=$(defaults read "$app_path/Contents/Info.plist" CFBundleIdentifier 2> /dev/null || echo "unknown") - fi + local app_mtime + app_mtime=$(get_file_mtime "$app_path") - if should_protect_from_uninstall "$bundle_id"; then - continue - fi + local cached_identity cached_bundle_id cached_display_name + cached_identity=$(lookup_cached_identity "$app_path" "$app_mtime") + IFS='|' read -r cached_bundle_id cached_display_name <<< "$cached_identity" - # Store tuple for pass 2 (metadata + size). - app_data_tuples+=("${app_path}|${app_name}|${bundle_id}") + # Store tuple for pass 2 (bundle + display resolution, then cache merge). + app_data_tuples+=("${app_path}|${app_name}|${app_mtime}|${cached_bundle_id}|${cached_display_name}") done < <(command find "$app_dir" -name "*.app" -maxdepth 3 -print0 2> /dev/null) done if [[ ${#app_data_tuples[@]} -eq 0 ]]; then - rm -f "$temp_file" + rm -f "$temp_file" "$scan_raw_file" "$merged_file" "$refresh_file" "$cache_snapshot_file" "${temp_file}.sorted" "${temp_file}.progress" "$spinner_shown_file" 2> /dev/null || true + [[ $cache_source_is_temp == true ]] && rm -f "$cache_source" 2> /dev/null || true + restore_scan_int_trap printf "\r\033[K" >&2 echo "No applications found to uninstall." >&2 return 1 fi - # Pass 2: metadata + size in parallel (mdls is slow). + # Pass 2: resolve display names in parallel. local app_count=0 local total_apps=${#app_data_tuples[@]} local max_parallel @@ -176,97 +461,33 @@ scan_applications() { process_app_metadata() { local app_data_tuple="$1" local output_file="$2" - local current_epoch="$3" - IFS='|' read -r app_path app_name bundle_id <<< "$app_data_tuple" + IFS='|' read -r app_path app_name app_mtime cached_bundle_id cached_display_name <<< "$app_data_tuple" - # Display name priority: mdls display name → bundle display → bundle name → folder. - local display_name="$app_name" - if [[ -f "$app_path/Contents/Info.plist" ]]; then - local md_display_name - md_display_name=$(run_with_timeout 0.05 mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "") - - local bundle_display_name - bundle_display_name=$(plutil -extract CFBundleDisplayName raw "$app_path/Contents/Info.plist" 2> /dev/null) - local bundle_name - bundle_name=$(plutil -extract CFBundleName raw "$app_path/Contents/Info.plist" 2> /dev/null) - - if [[ "$md_display_name" == /* ]]; then md_display_name=""; fi - md_display_name="${md_display_name//|/-}" - md_display_name="${md_display_name//[$'\t\r\n']/}" - - bundle_display_name="${bundle_display_name//|/-}" - bundle_display_name="${bundle_display_name//[$'\t\r\n']/}" - - bundle_name="${bundle_name//|/-}" - bundle_name="${bundle_name//[$'\t\r\n']/}" - - if [[ -n "$md_display_name" && "$md_display_name" != "(null)" && "$md_display_name" != "$app_name" ]]; then - display_name="$md_display_name" - elif [[ -n "$bundle_display_name" && "$bundle_display_name" != "(null)" ]]; then - display_name="$bundle_display_name" - elif [[ -n "$bundle_name" && "$bundle_name" != "(null)" ]]; then - display_name="$bundle_name" + local bundle_id="${cached_bundle_id:-}" + if [[ -z "$bundle_id" ]]; then + bundle_id="unknown" + if [[ -f "$app_path/Contents/Info.plist" ]]; then + bundle_id=$(defaults read "$app_path/Contents/Info.plist" CFBundleIdentifier 2> /dev/null || echo "unknown") fi fi - if [[ "$display_name" == /* ]]; then - display_name="$app_name" + if should_protect_from_uninstall "$bundle_id"; then + return 0 fi + + local display_name="${cached_display_name:-}" + if [[ -z "$display_name" ]]; then + display_name=$(uninstall_resolve_display_name "$app_path" "$app_name") + fi + + display_name="${display_name%.app}" display_name="${display_name//|/-}" display_name="${display_name//[$'\t\r\n']/}" - # App size (KB → human). - local app_size="N/A" - local app_size_kb="0" - if [[ -d "$app_path" ]]; then - app_size_kb=$(get_path_size_kb "$app_path") - app_size=$(bytes_to_human "$((app_size_kb * 1024))") - fi - - # Last used: mdls (fast timeout) → mtime. - local last_used="Never" - local last_used_epoch=0 - - if [[ -d "$app_path" ]]; then - local metadata_date - metadata_date=$(run_with_timeout 0.1 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "") - - if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then - last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0") - fi - - if [[ "$last_used_epoch" -eq 0 ]]; then - last_used_epoch=$(get_file_mtime "$app_path") - fi - - if [[ $last_used_epoch -gt 0 ]]; then - local days_ago=$(((current_epoch - last_used_epoch) / 86400)) - - if [[ $days_ago -eq 0 ]]; then - last_used="Today" - elif [[ $days_ago -eq 1 ]]; then - last_used="Yesterday" - elif [[ $days_ago -lt 7 ]]; then - last_used="${days_ago} days ago" - elif [[ $days_ago -lt 30 ]]; then - local weeks_ago=$((days_ago / 7)) - [[ $weeks_ago -eq 1 ]] && last_used="1 week ago" || last_used="${weeks_ago} weeks ago" - elif [[ $days_ago -lt 365 ]]; then - local months_ago=$((days_ago / 30)) - [[ $months_ago -eq 1 ]] && last_used="1 month ago" || last_used="${months_ago} months ago" - else - local years_ago=$((days_ago / 365)) - [[ $years_ago -eq 1 ]] && last_used="1 year ago" || last_used="${years_ago} years ago" - fi - fi - fi - - echo "${last_used_epoch}|${app_path}|${display_name}|${bundle_id}|${app_size}|${last_used}|${app_size_kb}" >> "$output_file" + echo "${app_path}|${display_name}|${bundle_id}|${app_mtime}" >> "$output_file" } - export -f process_app_metadata - local progress_file="${temp_file}.progress" echo "0" > "$progress_file" @@ -274,16 +495,15 @@ scan_applications() { # shellcheck disable=SC2329 # Function invoked indirectly via trap cleanup_spinner() { exit 0; } trap cleanup_spinner TERM INT EXIT + sleep "$MOLE_UNINSTALL_SCAN_SPINNER_DELAY_SEC" 2> /dev/null || sleep 1 + [[ -f "$progress_file" ]] || exit 0 local spinner_chars="|/-\\" local i=0 + : > "$spinner_shown_file" while true; do local completed=$(cat "$progress_file" 2> /dev/null || echo 0) local c="${spinner_chars:$((i % 4)):1}" - if [[ $inline_loading == true ]]; then - printf "\033[H\033[2K%s Scanning applications... %d/%d\n" "$c" "$completed" "$total_apps" >&2 - else - printf "\r\033[K%s Scanning applications... %d/%d" "$c" "$completed" "$total_apps" >&2 - fi + printf "\r\033[K%s Scanning applications... %d/%d" "$c" "$completed" "$total_apps" >&2 ((i++)) sleep 0.1 2> /dev/null || sleep 1 done @@ -292,7 +512,7 @@ scan_applications() { for app_data_tuple in "${app_data_tuples[@]}"; do ((app_count++)) - process_app_metadata "$app_data_tuple" "$temp_file" "$current_epoch" & + process_app_metadata "$app_data_tuple" "$scan_raw_file" & pids+=($!) echo "$app_count" > "$progress_file" @@ -310,47 +530,127 @@ scan_applications() { kill -TERM "$spinner_pid" 2> /dev/null || true wait "$spinner_pid" 2> /dev/null || true fi - if [[ $inline_loading == true ]]; then - printf "\033[H\033[2K" >&2 - else + if [[ -f "$spinner_shown_file" ]]; then echo -ne "\r\033[K" >&2 fi - rm -f "$progress_file" + rm -f "$progress_file" "$spinner_shown_file" - if [[ ! -s "$temp_file" ]]; then + if [[ ! -s "$scan_raw_file" ]]; then echo "No applications found to uninstall" >&2 - rm -f "$temp_file" + rm -f "$temp_file" "$scan_raw_file" "$merged_file" "$refresh_file" "$cache_snapshot_file" "${temp_file}.sorted" "${temp_file}.progress" "$spinner_shown_file" 2> /dev/null || true + [[ $cache_source_is_temp == true ]] && rm -f "$cache_source" 2> /dev/null || true + restore_scan_int_trap return 1 fi if [[ $total_apps -gt 50 ]]; then - if [[ $inline_loading == true ]]; then - printf "\033[H\033[2KProcessing %d applications...\n" "$total_apps" >&2 + printf "\rProcessing %d applications... " "$total_apps" >&2 + fi + + awk -F'|' ' + NR == FNR { + cache_mtime[$1] = $2 + cache_size[$1] = $3 + cache_epoch[$1] = $4 + cache_updated[$1] = $5 + cache_bundle[$1] = $6 + cache_display[$1] = $7 + next + } + { + print $0 "|" cache_mtime[$1] "|" cache_size[$1] "|" cache_epoch[$1] "|" cache_updated[$1] "|" cache_bundle[$1] "|" cache_display[$1] + } + ' "$cache_source" "$scan_raw_file" > "$merged_file" + if [[ ! -s "$merged_file" && -s "$scan_raw_file" ]]; then + awk '{print $0 "||||||"}' "$scan_raw_file" > "$merged_file" + fi + + local current_epoch + current_epoch=$(get_epoch_seconds) + + while IFS='|' read -r app_path display_name bundle_id app_mtime cached_mtime cached_size_kb cached_epoch cached_updated_epoch cached_bundle_id cached_display_name; do + [[ -n "$app_path" && -e "$app_path" ]] || continue + + local cache_match=false + if [[ -n "$cached_mtime" && -n "$app_mtime" && "$cached_mtime" == "$app_mtime" ]]; then + cache_match=true + fi + + local final_epoch=0 + if [[ "$cached_epoch" =~ ^[0-9]+$ && $cached_epoch -gt 0 ]]; then + final_epoch="$cached_epoch" + fi + + local final_size_kb=0 + local final_size="..." + if [[ "$cached_size_kb" =~ ^[0-9]+$ && $cached_size_kb -gt 0 ]]; then + final_size_kb="$cached_size_kb" + final_size=$(bytes_to_human "$((cached_size_kb * 1024))") + fi + + local final_last_used + final_last_used=$(uninstall_relative_time_from_epoch "$final_epoch" "$current_epoch") + + local needs_refresh=false + if [[ $cache_match == false ]]; then + needs_refresh=true + elif [[ ! "$cached_size_kb" =~ ^[0-9]+$ || $cached_size_kb -le 0 ]]; then + needs_refresh=true + elif [[ ! "$cached_epoch" =~ ^[0-9]+$ || $cached_epoch -le 0 ]]; then + needs_refresh=true + elif [[ ! "$cached_updated_epoch" =~ ^[0-9]+$ ]]; then + needs_refresh=true + elif [[ -z "$cached_bundle_id" || -z "$cached_display_name" ]]; then + needs_refresh=true else - printf "\rProcessing %d applications... " "$total_apps" >&2 + local cache_age=$((current_epoch - cached_updated_epoch)) + if [[ $cache_age -gt $MOLE_UNINSTALL_META_REFRESH_TTL ]]; then + needs_refresh=true + fi + fi + + if [[ $needs_refresh == true ]]; then + printf "%s|%s|%s|%s\n" "$app_path" "${app_mtime:-0}" "$bundle_id" "$display_name" >> "$refresh_file" + fi + + local persist_updated_epoch=0 + if [[ "$cached_updated_epoch" =~ ^[0-9]+$ && $cached_updated_epoch -gt 0 ]]; then + persist_updated_epoch="$cached_updated_epoch" + fi + printf "%s|%s|%s|%s|%s|%s|%s\n" "$app_path" "${app_mtime:-0}" "${final_size_kb:-0}" "${final_epoch:-0}" "${persist_updated_epoch:-0}" "$bundle_id" "$display_name" >> "$cache_snapshot_file" + + echo "${final_epoch}|${app_path}|${display_name}|${bundle_id}|${final_size}|${final_last_used}|${final_size_kb}" >> "$temp_file" + done < "$merged_file" + + if [[ -s "$cache_snapshot_file" ]]; then + if uninstall_acquire_metadata_lock "$MOLE_UNINSTALL_META_CACHE_LOCK"; then + mv "$cache_snapshot_file" "$MOLE_UNINSTALL_META_CACHE_FILE" 2> /dev/null || { + cp "$cache_snapshot_file" "$MOLE_UNINSTALL_META_CACHE_FILE" 2> /dev/null || true + rm -f "$cache_snapshot_file" + } + uninstall_release_metadata_lock "$MOLE_UNINSTALL_META_CACHE_LOCK" fi fi sort -t'|' -k1,1n "$temp_file" > "${temp_file}.sorted" || { - rm -f "$temp_file" + rm -f "$temp_file" "$scan_raw_file" "$merged_file" "$refresh_file" "$cache_snapshot_file" + [[ $cache_source_is_temp == true ]] && rm -f "$cache_source" 2> /dev/null || true + restore_scan_int_trap return 1 } - rm -f "$temp_file" + rm -f "$temp_file" "$scan_raw_file" "$merged_file" "$cache_snapshot_file" + [[ $cache_source_is_temp == true ]] && rm -f "$cache_source" 2> /dev/null || true - if [[ $total_apps -gt 50 ]]; then - if [[ $inline_loading == true ]]; then - printf "\033[H\033[2K" >&2 - else - printf "\r\033[K" >&2 - fi - fi + [[ $total_apps -gt 50 ]] && printf "\r\033[K" >&2 - ensure_user_file "$cache_file" - cp "${temp_file}.sorted" "$cache_file" 2> /dev/null || true + start_uninstall_metadata_refresh "$refresh_file" if [[ -f "${temp_file}.sorted" ]]; then + restore_scan_int_trap echo "${temp_file}.sorted" + return 0 else + restore_scan_int_trap return 1 fi } @@ -405,7 +705,6 @@ main() { export MOLE_CURRENT_COMMAND="uninstall" log_operation_session_start "uninstall" - local force_rescan=false # Global flags for arg in "$@"; do case "$arg" in @@ -415,69 +714,22 @@ main() { esac done - local use_inline_loading=false - if [[ -t 1 && -t 2 ]]; then - use_inline_loading=true - fi - hide_cursor while true; do - local needs_scanning=true - local cache_file="$HOME/.cache/mole/app_scan_cache" - if [[ $force_rescan == false && -f "$cache_file" ]]; then - local cache_age=$(($(get_epoch_seconds) - $(get_file_mtime "$cache_file"))) - [[ $cache_age -eq $(get_epoch_seconds) ]] && cache_age=86401 - [[ $cache_age -lt 86400 ]] && needs_scanning=false - fi - - if [[ $needs_scanning == true && $use_inline_loading == true ]]; then - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" != "1" ]]; then - enter_alt_screen - export MOLE_ALT_SCREEN_ACTIVE=1 - export MOLE_INLINE_LOADING=1 - export MOLE_MANAGED_ALT_SCREEN=1 - fi - printf "\033[2J\033[H" >&2 - else - unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN MOLE_ALT_SCREEN_ACTIVE - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE - fi - fi + unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN local apps_file="" - if ! apps_file=$(scan_applications "$force_rescan"); then - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - printf "\033[2J\033[H" >&2 - leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE - unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN - fi + if ! apps_file=$(scan_applications); then return 1 fi - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - printf "\033[2J\033[H" >&2 - fi - if [[ ! -f "$apps_file" ]]; then - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE - unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN - fi return 1 fi if ! load_applications "$apps_file"; then - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE - unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN - fi - [[ "$apps_file" != "$cache_file" ]] && rm -f "$apps_file" + rm -f "$apps_file" return 1 fi @@ -487,38 +739,21 @@ main() { set -e if [[ $exit_code -ne 0 ]]; then - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE - unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN - fi show_cursor clear_screen printf '\033[2J\033[H' >&2 - # Only delete temp files, never the permanent cache - [[ "$apps_file" != "$cache_file" ]] && rm -f "$apps_file" - - if [[ $exit_code -eq 10 ]]; then - force_rescan=true - continue - fi + rm -f "$apps_file" return 0 fi - if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then - leave_alt_screen - unset MOLE_ALT_SCREEN_ACTIVE - unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN - fi - show_cursor clear_screen printf '\033[2J\033[H' >&2 local selection_count=${#selected_apps[@]} if [[ $selection_count -eq 0 ]]; then echo "No apps selected" - [[ "$apps_file" != "$cache_file" ]] && rm -f "$apps_file" + rm -f "$apps_file" continue fi echo -e "${BLUE}${ICON_CONFIRM}${NC} Selected ${selection_count} apps:" @@ -531,9 +766,10 @@ main() { local name_width=$(get_display_width "$app_name") [[ $name_width -gt $max_name_display_width ]] && max_name_display_width=$name_width local size_display="$size" - [[ -z "$size_display" || "$size_display" == "0" || "$size_display" == "N/A" ]] && size_display="Unknown" + [[ -z "$size_display" || "$size_display" == "0" || "$size_display" == "N/A" || "$size_display" == "Unknown" ]] && size_display="..." [[ ${#size_display} -gt $max_size_width ]] && max_size_width=${#size_display} local last_display=$(format_last_used_summary "$last_used") + [[ -z "$last_display" || "$last_display" == "Unknown" || "$last_display" == "Never" ]] && last_display="..." [[ ${#last_display} -gt $max_last_width ]] && max_last_width=${#last_display} done ((max_size_width < 5)) && max_size_width=5 @@ -570,12 +806,13 @@ main() { [[ $current_width -gt $max_name_display_width ]] && max_name_display_width=$current_width local size_display="$size" - if [[ -z "$size_display" || "$size_display" == "0" || "$size_display" == "N/A" ]]; then - size_display="Unknown" + if [[ -z "$size_display" || "$size_display" == "0" || "$size_display" == "N/A" || "$size_display" == "Unknown" ]]; then + size_display="..." fi local last_display last_display=$(format_last_used_summary "$last_used") + [[ -z "$last_display" || "$last_display" == "Unknown" || "$last_display" == "Never" ]] && last_display="..." summary_rows+=("$display_name|$size_display|$last_display") done @@ -597,8 +834,7 @@ main() { batch_uninstall_applications - # Only delete temp files, never the permanent cache - [[ "$apps_file" != "$cache_file" ]] && rm -f "$apps_file" + rm -f "$apps_file" echo -e "${GRAY}Press Enter to return to application list, any other key to exit...${NC}" local key @@ -612,7 +848,6 @@ main() { return 0 fi - force_rescan=false done } From 02843dee743cc985bc16629f62f5848ddf437aae Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 7 Feb 2026 10:23:35 +0800 Subject: [PATCH 55/72] feat(ui): add Ctrl+U support and optimize paginated menu performance --- lib/core/ui.sh | 2 + lib/ui/app_selector.sh | 26 +- lib/ui/menu_paginated.sh | 770 +++++++++++++++++++++------------------ 3 files changed, 428 insertions(+), 370 deletions(-) diff --git a/lib/core/ui.sh b/lib/core/ui.sh index e7c2597..eb0c76c 100755 --- a/lib/core/ui.sh +++ b/lib/core/ui.sh @@ -170,6 +170,7 @@ read_key() { case "$key" in $'\n' | $'\r') echo "ENTER" ;; $'\x7f' | $'\x08') echo "DELETE" ;; + $'\x15') echo "CLEAR_LINE" ;; # Ctrl+U (often mapped from Cmd+Delete in terminals) $'\x1b') if IFS= read -r -s -n 1 -t 1 rest 2> /dev/null; then if [[ "$rest" == "[" ]]; then @@ -230,6 +231,7 @@ read_key() { 'l' | 'L') echo "RIGHT" ;; $'\x03') echo "QUIT" ;; $'\x7f' | $'\x08') echo "DELETE" ;; + $'\x15') echo "CLEAR_LINE" ;; # Ctrl+U $'\x1b') if IFS= read -r -s -n 1 -t 1 rest 2> /dev/null; then if [[ "$rest" == "[" ]]; then diff --git a/lib/ui/app_selector.sh b/lib/ui/app_selector.sh index 285ef04..3c15c72 100755 --- a/lib/ui/app_selector.sh +++ b/lib/ui/app_selector.sh @@ -12,9 +12,12 @@ format_app_display() { # Use common function from ui.sh to format last used time local compact_last_used compact_last_used=$(format_last_used_summary "$last_used") + if [[ -z "$compact_last_used" || "$compact_last_used" == "Unknown" || "$compact_last_used" == "Never" ]]; then + compact_last_used="..." + fi # Format size - local size_str="Unknown" + local size_str="..." [[ "$size" != "0" && "$size" != "" && "$size" != "Unknown" ]] && size_str="$size" # Calculate available width for app name based on terminal width @@ -114,10 +117,14 @@ select_apps_for_uninstall() { local epochs_csv="" local sizekb_csv="" local -a names_arr=() + local has_epoch_metadata=false + local has_size_metadata=false local idx=0 for app_data in "${apps_data[@]}"; do IFS='|' read -r epoch _ display_name _ size last_used size_kb <<< "$app_data" menu_options+=("$(format_app_display "$display_name" "$size" "$last_used" "$terminal_width" "$max_name_width")") + [[ "${epoch:-0}" =~ ^[0-9]+$ && "${epoch:-0}" -gt 0 ]] && has_epoch_metadata=true + [[ "${size_kb:-0}" =~ ^[0-9]+$ && "${size_kb:-0}" -gt 0 ]] && has_size_metadata=true if [[ $idx -eq 0 ]]; then epochs_csv="${epoch:-0}" sizekb_csv="${size_kb:-0}" @@ -143,8 +150,16 @@ select_apps_for_uninstall() { # - 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" + if [[ $has_epoch_metadata == true ]]; then + export MOLE_MENU_META_EPOCHS="$epochs_csv" + else + unset MOLE_MENU_META_EPOCHS + fi + if [[ $has_size_metadata == true ]]; then + export MOLE_MENU_META_SIZEKB="$sizekb_csv" + else + unset MOLE_MENU_META_SIZEKB + fi export MOLE_MENU_FILTER_NAMES="$names_newline" # Use paginated menu - result will be stored in MOLE_SELECTION_RESULT @@ -157,11 +172,6 @@ select_apps_for_uninstall() { unset MOLE_MENU_META_EPOCHS MOLE_MENU_META_SIZEKB # leave MOLE_MENU_SORT_DEFAULT untouched if user set it globally - # Refresh signal handling - if [[ $exit_code -eq 10 ]]; then - return 10 - fi - if [[ $exit_code -ne 0 ]]; then return 1 fi diff --git a/lib/ui/menu_paginated.sh b/lib/ui/menu_paginated.sh index 05316cd..e806edc 100755 --- a/lib/ui/menu_paginated.sh +++ b/lib/ui/menu_paginated.sh @@ -5,13 +5,13 @@ set -euo pipefail # Terminal control functions enter_alt_screen() { - if command -v tput > /dev/null 2>&1 && [[ -t 1 ]]; then - tput smcup 2> /dev/null || true + 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 + if command -v tput >/dev/null 2>&1 && [[ -t 1 ]]; then + tput rmcup 2>/dev/null || true fi } @@ -22,13 +22,13 @@ _pm_get_terminal_height() { # Try stty size first (most reliable, real-time) # Use /dev/null | awk '{print $1}') + height=$(stty size /dev/null | awk '{print $1}') fi # Fallback to tput if [[ -z "$height" || $height -le 0 ]]; then - if command -v tput > /dev/null 2>&1; then - height=$(tput lines 2> /dev/null || echo "24") + if command -v tput >/dev/null 2>&1; then + height=$(tput lines 2>/dev/null || echo "24") else height=24 fi @@ -90,6 +90,7 @@ paginated_multi_select() { local sort_mode="${MOLE_MENU_SORT_MODE:-${MOLE_MENU_SORT_DEFAULT:-date}}" # date|name|size local sort_reverse="${MOLE_MENU_SORT_REVERSE:-false}" local filter_text="" # Filter keyword + local filter_text_lower="" # Metadata (optional) # epochs[i] -> last_used_epoch (numeric) for item i @@ -109,7 +110,7 @@ paginated_multi_select() { has_metadata="true" fi if [[ -n "${MOLE_MENU_FILTER_NAMES:-}" ]]; then - while IFS= read -r v; do filter_names+=("$v"); done <<< "$MOLE_MENU_FILTER_NAMES" + while IFS= read -r v; do filter_names+=("$v"); done <<<"$MOLE_MENU_FILTER_NAMES" has_filter_names="true" fi @@ -121,10 +122,20 @@ paginated_multi_select() { # Index mappings local -a orig_indices=() local -a view_indices=() + local -a filter_targets_lower=() local i for ((i = 0; i < total_items; i++)); do orig_indices[i]=$i view_indices[i]=$i + local filter_target + if [[ $has_filter_names == true && -n "${filter_names[i]:-}" ]]; then + filter_target="${filter_names[i]}" + else + filter_target="${items[i]}" + fi + local filter_target_lower + filter_target_lower=$(printf "%s" "$filter_target" | LC_ALL=C tr '[:upper:]' '[:lower:]') + filter_targets_lower[i]="$filter_target_lower" done local -a selected=() @@ -138,7 +149,7 @@ paginated_multi_select() { if [[ -n "${MOLE_PRESELECTED_INDICES:-}" ]]; then local cleaned_preselect="${MOLE_PRESELECTED_INDICES//[[:space:]]/}" local -a initial_indices=() - IFS=',' read -ra initial_indices <<< "$cleaned_preselect" + 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 # Only count if not already selected (handles duplicates) @@ -152,16 +163,16 @@ paginated_multi_select() { # 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 "") + 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 + 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 + stty sane 2>/dev/null || stty echo icanon 2>/dev/null || true fi if [[ "${external_alt_screen:-false}" == false ]]; then leave_alt_screen @@ -171,8 +182,8 @@ paginated_multi_select() { # Cleanup function cleanup() { trap - EXIT INT TERM - export MOLE_MENU_SORT_MODE="$sort_mode" - export MOLE_MENU_SORT_REVERSE="$sort_reverse" + export MOLE_MENU_SORT_MODE="${sort_mode:-name}" + export MOLE_MENU_SORT_REVERSE="${sort_reverse:-false}" restore_terminal } @@ -187,7 +198,7 @@ paginated_multi_select() { trap handle_interrupt INT TERM # Setup terminal - preserve interrupt character - stty -echo -icanon intr ^C 2> /dev/null || true + 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 @@ -208,7 +219,7 @@ paginated_multi_select() { local -a segs=("$@") local cols="${COLUMNS:-}" - [[ -z "$cols" ]] && cols=$(tput cols 2> /dev/null || echo 80) + [[ -z "$cols" ]] && cols=$(tput cols 2>/dev/null || echo 80) [[ "$cols" =~ ^[0-9]+$ ]] || cols=80 _strip_ansi_len() { @@ -240,75 +251,111 @@ paginated_multi_select() { printf "%s%s\n" "$clear_line" "$line" >&2 } - # Rebuild the view_indices applying filter and sort - rebuild_view() { - local -a active_indices=() - if [[ -n "$filter_text" ]]; then - local filter_lower - filter_lower=$(printf "%s" "$filter_text" | LC_ALL=C tr '[:upper:]' '[:lower:]') - for id in "${orig_indices[@]}"; do - local filter_target - if [[ $has_filter_names == true && -n "${filter_names[id]:-}" ]]; then - filter_target="${filter_names[id]}" - else - filter_target="${items[id]}" - fi - local target_lower - target_lower=$(printf "%s" "$filter_target" | LC_ALL=C tr '[:upper:]' '[:lower:]') - if [[ "$target_lower" == *"$filter_lower"* ]]; then - active_indices+=("$id") - fi - done - else - active_indices=("${orig_indices[@]}") + local sort_cache_key="" + local -a sorted_indices_cache=() + local filter_cache_key="" + local filter_cache_text_lower="" + local -a filter_cache_indices=() + + ensure_sorted_indices() { + local requested_key="${sort_mode}:${sort_reverse}:${has_metadata}" + if [[ "$requested_key" == "$sort_cache_key" && ${#sorted_indices_cache[@]} -gt 0 ]]; then + return fi - # Sort filtered results if [[ "$has_metadata" == "false" ]]; then - view_indices=("${active_indices[@]}") - elif [[ ${#active_indices[@]} -eq 0 ]]; then - view_indices=() + sorted_indices_cache=("${orig_indices[@]}") + sort_cache_key="$requested_key" + return + fi + + # Build sort key once; filtering should reuse this cached order. + 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 - # 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" + # Name: ascending by default (A to Z) + sort_key="-k1,1f" + [[ "$sort_reverse" == "true" ]] && sort_key="-k1,1fr" + fi + + local tmpfile + tmpfile=$(mktemp 2>/dev/null) || tmpfile="" + if [[ -n "$tmpfile" ]]; then + local k id + for id in "${orig_indices[@]}"; do + case "$sort_mode" in + 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 + + sorted_indices_cache=() + while IFS=$'\t' read -r _key _id; do + [[ -z "$_id" ]] && continue + sorted_indices_cache+=("$_id") + done < <(LC_ALL=C sort -t $'\t' $sort_key -- "$tmpfile" 2>/dev/null) + + rm -f "$tmpfile" + else + sorted_indices_cache=("${orig_indices[@]}") + fi + sort_cache_key="$requested_key" + } + + # Rebuild the view_indices applying filter over cached sort order + rebuild_view() { + ensure_sorted_indices + + if [[ -n "$filter_text_lower" ]]; then + local -a source_indices=() + if [[ "$filter_cache_key" == "$sort_cache_key" && + "$filter_text_lower" == "$filter_cache_text_lower"* && + ${#filter_cache_indices[@]} -gt 0 ]]; then + source_indices=("${filter_cache_indices[@]}") else - # Name: ascending by default (A to Z) - sort_key="-k1,1f" - [[ "$sort_reverse" == "true" ]] && sort_key="-k1,1fr" + if [[ ${#sorted_indices_cache[@]} -gt 0 ]]; then + source_indices=("${sorted_indices_cache[@]}") + else + source_indices=() + fi fi - # Create temporary file for sorting - local tmpfile - tmpfile=$(mktemp 2> /dev/null) || tmpfile="" - if [[ -n "$tmpfile" ]]; then - local k id - for id in "${active_indices[@]}"; do - case "$sort_mode" in - 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 + view_indices=() + local id + for id in "${source_indices[@]}"; do + if [[ "${filter_targets_lower[id]:-}" == *"$filter_text_lower"* ]]; then + view_indices+=("$id") + fi + done - 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" + filter_cache_key="$sort_cache_key" + filter_cache_text_lower="$filter_text_lower" + if [[ ${#view_indices[@]} -gt 0 ]]; then + filter_cache_indices=("${view_indices[@]}") else - # Fallback: no sorting - view_indices=("${active_indices[@]}") + filter_cache_indices=() + fi + else + if [[ ${#sorted_indices_cache[@]} -gt 0 ]]; then + view_indices=("${sorted_indices_cache[@]}") + else + view_indices=() + fi + filter_cache_key="$sort_cache_key" + filter_cache_text_lower="" + if [[ ${#view_indices[@]} -gt 0 ]]; then + filter_cache_indices=("${view_indices[@]}") + else + filter_cache_indices=() fi fi @@ -368,7 +415,10 @@ paginated_multi_select() { return 1 fi if [[ "$char" =~ ^[[:print:]]$ ]]; then + local char_lower + char_lower=$(printf "%s" "$char" | LC_ALL=C tr '[:upper:]' '[:lower:]') filter_text+="$char" + filter_text_lower+="$char_lower" rebuild_view cursor_pos=0 top_index=0 @@ -432,9 +482,9 @@ paginated_multi_select() { # Build sort status local sort_label="" case "$sort_mode" in - date) sort_label="Date" ;; - name) sort_label="Name" ;; - size) sort_label="Size" ;; + date) sort_label="Date" ;; + name) sort_label="Name" ;; + size) sort_label="Size" ;; esac local sort_status="${sort_label}" @@ -458,22 +508,21 @@ paginated_multi_select() { local reverse_arrow="↑" [[ "$sort_reverse" == "true" ]] && reverse_arrow="↓" - local refresh="${GRAY}R Refresh${NC}" local sort_ctrl="${GRAY}S ${sort_status}${NC}" local order_ctrl="${GRAY}O ${reverse_arrow}${NC}" local filter_ctrl="${GRAY}/ Filter${NC}" if [[ -n "$filter_text" ]]; then - local -a _segs_filter=("${GRAY}Backspace${NC}" "${GRAY}ESC Clear${NC}") + local -a _segs_filter=("${GRAY}Backspace${NC}" "${GRAY}Ctrl+U Clear${NC}" "${GRAY}ESC Clear${NC}") _print_wrapped_controls "$sep" "${_segs_filter[@]}" elif [[ "$has_metadata" == "true" ]]; then # With metadata: show sort controls local term_width="${COLUMNS:-}" - [[ -z "$term_width" ]] && term_width=$(tput cols 2> /dev/null || echo 80) + [[ -z "$term_width" ]] && term_width=$(tput cols 2>/dev/null || echo 80) [[ "$term_width" =~ ^[0-9]+$ ]] || term_width=80 # Full controls - local -a _segs=("$nav" "$space_select" "$enter" "$refresh" "$sort_ctrl" "$order_ctrl" "$filter_ctrl" "$exit") + local -a _segs=("$nav" "$space_select" "$enter" "$sort_ctrl" "$order_ctrl" "$filter_ctrl" "$exit") # Calculate width local total_len=0 seg_count=${#_segs[@]} @@ -484,7 +533,7 @@ paginated_multi_select() { # Level 1: Remove "Space Select" if too wide if [[ $total_len -gt $term_width ]]; then - _segs=("$nav" "$enter" "$refresh" "$sort_ctrl" "$order_ctrl" "$filter_ctrl" "$exit") + _segs=("$nav" "$enter" "$sort_ctrl" "$order_ctrl" "$filter_ctrl" "$exit") total_len=0 seg_count=${#_segs[@]} @@ -495,14 +544,14 @@ paginated_multi_select() { # Level 2: Remove sort label if still too wide if [[ $total_len -gt $term_width ]]; then - _segs=("$nav" "$enter" "$refresh" "$order_ctrl" "$filter_ctrl" "$exit") + _segs=("$nav" "$enter" "$order_ctrl" "$filter_ctrl" "$exit") fi fi _print_wrapped_controls "$sep" "${_segs[@]}" else # Without metadata: basic controls - local -a _segs_simple=("$nav" "$space_select" "$enter" "$refresh" "$filter_ctrl" "$exit") + local -a _segs_simple=("$nav" "$space_select" "$enter" "$filter_ctrl" "$exit") _print_wrapped_controls "$sep" "${_segs_simple[@]}" fi printf "${clear_line}" >&2 @@ -527,300 +576,297 @@ paginated_multi_select() { key=$(read_key) case "$key" in - "QUIT") + "QUIT") + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + filter_text="" + filter_text_lower="" + unset MOLE_READ_KEY_FORCE_CHAR + rebuild_view + cursor_pos=0 + top_index=0 + need_full_redraw=true + else + cleanup + return 1 + fi + ;; + "UP") + if [[ ${#view_indices[@]} -eq 0 ]]; then + : + elif [[ $cursor_pos -gt 0 ]]; then + local old_cursor=$cursor_pos + ((cursor_pos--)) + local new_cursor=$cursor_pos + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - filter_text="" - unset MOLE_READ_KEY_FORCE_CHAR - rebuild_view - cursor_pos=0 - top_index=0 - need_full_redraw=true - else - cleanup - return 1 + draw_header fi - ;; - "UP") - if [[ ${#view_indices[@]} -eq 0 ]]; then - : - elif [[ $cursor_pos -gt 0 ]]; then - local old_cursor=$cursor_pos - ((cursor_pos--)) - local new_cursor=$cursor_pos - if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - draw_header + local old_row=$((old_cursor + 3)) + local new_row=$((new_cursor + 3)) + + printf "\033[%d;1H" "$old_row" >&2 + render_item "$old_cursor" false + printf "\033[%d;1H" "$new_row" >&2 + render_item "$new_cursor" true + + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + + prev_cursor_pos=$cursor_pos + continue + elif [[ $top_index -gt 0 ]]; then + ((top_index--)) + + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + draw_header + fi + + local start_idx=$top_index + local end_idx=$((top_index + items_per_page - 1)) + local visible_total=${#view_indices[@]} + [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) + + for ((i = start_idx; i <= end_idx; i++)); do + local row=$((i - start_idx + 3)) + printf "\033[%d;1H" "$row" >&2 + local is_current=false + [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true + render_item $((i - start_idx)) $is_current + done + + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + + prev_cursor_pos=$cursor_pos + prev_top_index=$top_index + continue + fi + ;; + "DOWN") + if [[ ${#view_indices[@]} -eq 0 ]]; then + : + else + local absolute_index=$((top_index + cursor_pos)) + local last_index=$((${#view_indices[@]} - 1)) + if [[ $absolute_index -lt $last_index ]]; then + local visible_count=$((${#view_indices[@]} - top_index)) + [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page + + if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then + local old_cursor=$cursor_pos + ((cursor_pos++)) + local new_cursor=$cursor_pos + + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + draw_header + fi + + local old_row=$((old_cursor + 3)) + local new_row=$((new_cursor + 3)) + + printf "\033[%d;1H" "$old_row" >&2 + render_item "$old_cursor" false + printf "\033[%d;1H" "$new_row" >&2 + render_item "$new_cursor" true + + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + + prev_cursor_pos=$cursor_pos + continue + elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then + ((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)) + fi + + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + draw_header + fi + + local start_idx=$top_index + local end_idx=$((top_index + items_per_page - 1)) + local visible_total=${#view_indices[@]} + [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) + + for ((i = start_idx; i <= end_idx; i++)); do + local row=$((i - start_idx + 3)) + printf "\033[%d;1H" "$row" >&2 + local is_current=false + [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true + render_item $((i - start_idx)) $is_current + done + + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + + prev_cursor_pos=$cursor_pos + prev_top_index=$top_index + continue fi + fi + fi + ;; + "SPACE") + local idx=$((top_index + cursor_pos)) + if [[ $idx -lt ${#view_indices[@]} ]]; then + local real="${view_indices[idx]}" + if [[ ${selected[real]} == true ]]; then + selected[real]=false + ((selected_count--)) + else + selected[real]=true + ((selected_count++)) + fi - local old_row=$((old_cursor + 3)) - local new_row=$((new_cursor + 3)) + # Incremental update: only redraw header (for count) and current row + # Header is at row 1 + printf "\033[1;1H\033[2K${PURPLE_BOLD}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2 - printf "\033[%d;1H" "$old_row" >&2 - render_item "$old_cursor" false - printf "\033[%d;1H" "$new_row" >&2 - render_item "$new_cursor" true + # Redraw current item row (+3: row 1=header, row 2=blank, row 3=first item) + local item_row=$((cursor_pos + 3)) + printf "\033[%d;1H" "$item_row" >&2 + render_item "$cursor_pos" true - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + # Move cursor to footer to avoid visual artifacts (items + header + 2 blanks) + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - prev_cursor_pos=$cursor_pos - continue + continue # Skip full redraw + fi + ;; + "CHAR:s" | "CHAR:S") + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ "$has_metadata" == "true" ]]; then + case "$sort_mode" in + date) sort_mode="name" ;; + name) sort_mode="size" ;; + size) sort_mode="date" ;; + esac + rebuild_view + need_full_redraw=true + fi + ;; + "CHAR:j") + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ ${#view_indices[@]} -gt 0 ]]; then + local absolute_index=$((top_index + cursor_pos)) + local last_index=$((${#view_indices[@]} - 1)) + if [[ $absolute_index -lt $last_index ]]; then + local visible_count=$((${#view_indices[@]} - top_index)) + [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page + if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then + ((cursor_pos++)) + elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then + ((top_index++)) + fi + need_full_redraw=true + fi + fi + ;; + "CHAR:k") + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ ${#view_indices[@]} -gt 0 ]]; then + if [[ $cursor_pos -gt 0 ]]; then + ((cursor_pos--)) + need_full_redraw=true elif [[ $top_index -gt 0 ]]; then ((top_index--)) - - if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - draw_header - fi - - local start_idx=$top_index - local end_idx=$((top_index + items_per_page - 1)) - local visible_total=${#view_indices[@]} - [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) - - for ((i = start_idx; i <= end_idx; i++)); do - local row=$((i - start_idx + 3)) - printf "\033[%d;1H" "$row" >&2 - local is_current=false - [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true - render_item $((i - start_idx)) $is_current - done - - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - prev_cursor_pos=$cursor_pos - prev_top_index=$top_index - continue + need_full_redraw=true fi - ;; - "DOWN") - if [[ ${#view_indices[@]} -eq 0 ]]; then - : + fi + ;; + "CHAR:o" | "CHAR:O") + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ "$has_metadata" == "true" ]]; then + if [[ "$sort_reverse" == "true" ]]; then + sort_reverse="false" else - local absolute_index=$((top_index + cursor_pos)) - local last_index=$((${#view_indices[@]} - 1)) - if [[ $absolute_index -lt $last_index ]]; then - local visible_count=$((${#view_indices[@]} - top_index)) - [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page - - if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then - local old_cursor=$cursor_pos - ((cursor_pos++)) - local new_cursor=$cursor_pos - - if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - draw_header - fi - - local old_row=$((old_cursor + 3)) - local new_row=$((new_cursor + 3)) - - printf "\033[%d;1H" "$old_row" >&2 - render_item "$old_cursor" false - printf "\033[%d;1H" "$new_row" >&2 - render_item "$new_cursor" true - - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - prev_cursor_pos=$cursor_pos - continue - elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then - ((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)) - fi - - if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - draw_header - fi - - local start_idx=$top_index - local end_idx=$((top_index + items_per_page - 1)) - local visible_total=${#view_indices[@]} - [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) - - for ((i = start_idx; i <= end_idx; i++)); do - local row=$((i - start_idx + 3)) - printf "\033[%d;1H" "$row" >&2 - local is_current=false - [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true - render_item $((i - start_idx)) $is_current - done - - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - prev_cursor_pos=$cursor_pos - prev_top_index=$top_index - continue - fi - fi + sort_reverse="true" fi - ;; - "SPACE") + rebuild_view + need_full_redraw=true + fi + ;; + "CHAR:/" | "CHAR:?") + if [[ -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + unset MOLE_READ_KEY_FORCE_CHAR + else + export MOLE_READ_KEY_FORCE_CHAR=1 + fi + need_full_redraw=true + ;; + "DELETE") + if [[ -n "$filter_text" ]]; then + filter_text="${filter_text%?}" + filter_text_lower="${filter_text_lower%?}" + if [[ -z "$filter_text" ]]; then + filter_text_lower="" + unset MOLE_READ_KEY_FORCE_CHAR + fi + rebuild_view + cursor_pos=0 + top_index=0 + need_full_redraw=true + fi + ;; + "CLEAR_LINE") + if [[ -n "$filter_text" ]]; then + filter_text="" + filter_text_lower="" + rebuild_view + cursor_pos=0 + top_index=0 + need_full_redraw=true + fi + ;; + "CHAR:"*) + handle_filter_char "${key#CHAR:}" || true + ;; + "ENTER") + # 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]}" - if [[ ${selected[real]} == true ]]; then - selected[real]=false - ((selected_count--)) - else - selected[real]=true - ((selected_count++)) - fi + selected[real]=true + ((selected_count++)) + fi + fi - # Incremental update: only redraw header (for count) and current row - # Header is at row 1 - printf "\033[1;1H\033[2K${PURPLE_BOLD}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2 + # 3. Confirm and exit with current selections + local -a selected_indices=() + for ((i = 0; i < total_items; i++)); do + if [[ ${selected[i]} == true ]]; then + selected_indices+=("$i") + fi + done - # Redraw current item row (+3: row 1=header, row 2=blank, row 3=first item) - local item_row=$((cursor_pos + 3)) - printf "\033[%d;1H" "$item_row" >&2 - render_item "$cursor_pos" true + local final_result="" + if [[ ${#selected_indices[@]} -gt 0 ]]; then + local IFS=',' + final_result="${selected_indices[*]}" + fi - # Move cursor to footer to avoid visual artifacts (items + header + 2 blanks) - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - continue # Skip full redraw - fi - ;; - "RETRY") - # '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 - need_full_redraw=true - fi - ;; - "CHAR:s" | "CHAR:S") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - elif [[ "$has_metadata" == "true" ]]; then - case "$sort_mode" in - date) sort_mode="name" ;; - name) sort_mode="size" ;; - size) sort_mode="date" ;; - esac - rebuild_view - need_full_redraw=true - fi - ;; - "CHAR:j") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - elif [[ ${#view_indices[@]} -gt 0 ]]; then - local absolute_index=$((top_index + cursor_pos)) - local last_index=$((${#view_indices[@]} - 1)) - if [[ $absolute_index -lt $last_index ]]; then - local visible_count=$((${#view_indices[@]} - top_index)) - [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page - if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then - ((cursor_pos++)) - elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then - ((top_index++)) - fi - need_full_redraw=true - fi - fi - ;; - "CHAR:k") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - elif [[ ${#view_indices[@]} -gt 0 ]]; then - if [[ $cursor_pos -gt 0 ]]; then - ((cursor_pos--)) - need_full_redraw=true - elif [[ $top_index -gt 0 ]]; then - ((top_index--)) - need_full_redraw=true - fi - fi - ;; - "CHAR:r" | "CHAR:R") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - else - cleanup - return 10 - fi - ;; - "CHAR:o" | "CHAR:O") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - elif [[ "$has_metadata" == "true" ]]; then - if [[ "$sort_reverse" == "true" ]]; then - sort_reverse="false" - else - sort_reverse="true" - fi - rebuild_view - need_full_redraw=true - fi - ;; - "CHAR:/" | "CHAR:?") - export MOLE_READ_KEY_FORCE_CHAR=1 - need_full_redraw=true - ;; - "DELETE") - if [[ -n "$filter_text" ]]; then - filter_text="${filter_text%?}" - if [[ -z "$filter_text" ]]; then - unset MOLE_READ_KEY_FORCE_CHAR - fi - rebuild_view - cursor_pos=0 - top_index=0 - need_full_redraw=true - fi - ;; - "CHAR:"*) - handle_filter_char "${key#CHAR:}" || true - ;; - "ENTER") - # 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 - ((selected_count++)) - 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 - selected_indices+=("$i") - fi - done - - local final_result="" - if [[ ${#selected_indices[@]} -gt 0 ]]; then - local IFS=',' - final_result="${selected_indices[*]}" - fi - - trap - EXIT INT TERM - MOLE_SELECTION_RESULT="$final_result" - export MOLE_MENU_SORT_MODE="$sort_mode" - export MOLE_MENU_SORT_REVERSE="$sort_reverse" - restore_terminal - return 0 - ;; + trap - EXIT INT TERM + MOLE_SELECTION_RESULT="$final_result" + export MOLE_MENU_SORT_MODE="${sort_mode:-name}" + export MOLE_MENU_SORT_REVERSE="${sort_reverse:-false}" + restore_terminal + return 0 + ;; esac # Drain any accumulated input after processing (e.g., mouse wheel events) From 0ac3d6cfbb73b043fd7eb02295b6d82ed16c5fa4 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 7 Feb 2026 02:30:43 +0000 Subject: [PATCH 56/72] chore: auto format code --- lib/ui/menu_paginated.sh | 608 +++++++++++++++++++-------------------- 1 file changed, 304 insertions(+), 304 deletions(-) diff --git a/lib/ui/menu_paginated.sh b/lib/ui/menu_paginated.sh index e806edc..52e08d7 100755 --- a/lib/ui/menu_paginated.sh +++ b/lib/ui/menu_paginated.sh @@ -5,13 +5,13 @@ set -euo pipefail # Terminal control functions enter_alt_screen() { - if command -v tput >/dev/null 2>&1 && [[ -t 1 ]]; then - tput smcup 2>/dev/null || true + 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 + if command -v tput > /dev/null 2>&1 && [[ -t 1 ]]; then + tput rmcup 2> /dev/null || true fi } @@ -22,13 +22,13 @@ _pm_get_terminal_height() { # Try stty size first (most reliable, real-time) # Use /dev/null | awk '{print $1}') + height=$(stty size < /dev/tty 2> /dev/null | awk '{print $1}') fi # Fallback to tput if [[ -z "$height" || $height -le 0 ]]; then - if command -v tput >/dev/null 2>&1; then - height=$(tput lines 2>/dev/null || echo "24") + if command -v tput > /dev/null 2>&1; then + height=$(tput lines 2> /dev/null || echo "24") else height=24 fi @@ -110,7 +110,7 @@ paginated_multi_select() { has_metadata="true" fi if [[ -n "${MOLE_MENU_FILTER_NAMES:-}" ]]; then - while IFS= read -r v; do filter_names+=("$v"); done <<<"$MOLE_MENU_FILTER_NAMES" + while IFS= read -r v; do filter_names+=("$v"); done <<< "$MOLE_MENU_FILTER_NAMES" has_filter_names="true" fi @@ -149,7 +149,7 @@ paginated_multi_select() { if [[ -n "${MOLE_PRESELECTED_INDICES:-}" ]]; then local cleaned_preselect="${MOLE_PRESELECTED_INDICES//[[:space:]]/}" local -a initial_indices=() - IFS=',' read -ra initial_indices <<<"$cleaned_preselect" + 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 # Only count if not already selected (handles duplicates) @@ -163,16 +163,16 @@ paginated_multi_select() { # 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 "") + 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 + 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 + stty sane 2> /dev/null || stty echo icanon 2> /dev/null || true fi if [[ "${external_alt_screen:-false}" == false ]]; then leave_alt_screen @@ -198,7 +198,7 @@ paginated_multi_select() { trap handle_interrupt INT TERM # Setup terminal - preserve interrupt character - stty -echo -icanon intr ^C 2>/dev/null || true + 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 @@ -219,7 +219,7 @@ paginated_multi_select() { local -a segs=("$@") local cols="${COLUMNS:-}" - [[ -z "$cols" ]] && cols=$(tput cols 2>/dev/null || echo 80) + [[ -z "$cols" ]] && cols=$(tput cols 2> /dev/null || echo 80) [[ "$cols" =~ ^[0-9]+$ ]] || cols=80 _strip_ansi_len() { @@ -286,23 +286,23 @@ paginated_multi_select() { fi local tmpfile - tmpfile=$(mktemp 2>/dev/null) || tmpfile="" + tmpfile=$(mktemp 2> /dev/null) || tmpfile="" if [[ -n "$tmpfile" ]]; then local k id for id in "${orig_indices[@]}"; do case "$sort_mode" in - date) k="${epochs[id]:-0}" ;; - size) k="${sizekb[id]:-0}" ;; - name | *) k="${items[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" + printf "%s\t%s\n" "$k" "$id" >> "$tmpfile" done sorted_indices_cache=() while IFS=$'\t' read -r _key _id; do [[ -z "$_id" ]] && continue sorted_indices_cache+=("$_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 @@ -482,9 +482,9 @@ paginated_multi_select() { # Build sort status local sort_label="" case "$sort_mode" in - date) sort_label="Date" ;; - name) sort_label="Name" ;; - size) sort_label="Size" ;; + date) sort_label="Date" ;; + name) sort_label="Name" ;; + size) sort_label="Size" ;; esac local sort_status="${sort_label}" @@ -518,7 +518,7 @@ paginated_multi_select() { elif [[ "$has_metadata" == "true" ]]; then # With metadata: show sort controls local term_width="${COLUMNS:-}" - [[ -z "$term_width" ]] && term_width=$(tput cols 2>/dev/null || echo 80) + [[ -z "$term_width" ]] && term_width=$(tput cols 2> /dev/null || echo 80) [[ "$term_width" =~ ^[0-9]+$ ]] || term_width=80 # Full controls @@ -576,297 +576,297 @@ paginated_multi_select() { key=$(read_key) case "$key" in - "QUIT") - if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - filter_text="" - filter_text_lower="" - unset MOLE_READ_KEY_FORCE_CHAR - rebuild_view - cursor_pos=0 - top_index=0 - need_full_redraw=true - else - cleanup - return 1 - fi - ;; - "UP") - if [[ ${#view_indices[@]} -eq 0 ]]; then - : - elif [[ $cursor_pos -gt 0 ]]; then - local old_cursor=$cursor_pos - ((cursor_pos--)) - local new_cursor=$cursor_pos - + "QUIT") if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - draw_header - fi - - local old_row=$((old_cursor + 3)) - local new_row=$((new_cursor + 3)) - - printf "\033[%d;1H" "$old_row" >&2 - render_item "$old_cursor" false - printf "\033[%d;1H" "$new_row" >&2 - render_item "$new_cursor" true - - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - prev_cursor_pos=$cursor_pos - continue - elif [[ $top_index -gt 0 ]]; then - ((top_index--)) - - if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - draw_header - fi - - local start_idx=$top_index - local end_idx=$((top_index + items_per_page - 1)) - local visible_total=${#view_indices[@]} - [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) - - for ((i = start_idx; i <= end_idx; i++)); do - local row=$((i - start_idx + 3)) - printf "\033[%d;1H" "$row" >&2 - local is_current=false - [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true - render_item $((i - start_idx)) $is_current - done - - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - prev_cursor_pos=$cursor_pos - prev_top_index=$top_index - continue - fi - ;; - "DOWN") - if [[ ${#view_indices[@]} -eq 0 ]]; then - : - else - local absolute_index=$((top_index + cursor_pos)) - local last_index=$((${#view_indices[@]} - 1)) - if [[ $absolute_index -lt $last_index ]]; then - local visible_count=$((${#view_indices[@]} - top_index)) - [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page - - if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then - local old_cursor=$cursor_pos - ((cursor_pos++)) - local new_cursor=$cursor_pos - - if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - draw_header - fi - - local old_row=$((old_cursor + 3)) - local new_row=$((new_cursor + 3)) - - printf "\033[%d;1H" "$old_row" >&2 - render_item "$old_cursor" false - printf "\033[%d;1H" "$new_row" >&2 - render_item "$new_cursor" true - - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - prev_cursor_pos=$cursor_pos - continue - elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then - ((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)) - fi - - if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - draw_header - fi - - local start_idx=$top_index - local end_idx=$((top_index + items_per_page - 1)) - local visible_total=${#view_indices[@]} - [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) - - for ((i = start_idx; i <= end_idx; i++)); do - local row=$((i - start_idx + 3)) - printf "\033[%d;1H" "$row" >&2 - local is_current=false - [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true - render_item $((i - start_idx)) $is_current - done - - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - prev_cursor_pos=$cursor_pos - prev_top_index=$top_index - continue - fi - fi - fi - ;; - "SPACE") - local idx=$((top_index + cursor_pos)) - if [[ $idx -lt ${#view_indices[@]} ]]; then - local real="${view_indices[idx]}" - if [[ ${selected[real]} == true ]]; then - selected[real]=false - ((selected_count--)) - else - selected[real]=true - ((selected_count++)) - fi - - # Incremental update: only redraw header (for count) and current row - # Header is at row 1 - printf "\033[1;1H\033[2K${PURPLE_BOLD}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2 - - # Redraw current item row (+3: row 1=header, row 2=blank, row 3=first item) - local item_row=$((cursor_pos + 3)) - printf "\033[%d;1H" "$item_row" >&2 - render_item "$cursor_pos" true - - # Move cursor to footer to avoid visual artifacts (items + header + 2 blanks) - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - continue # Skip full redraw - fi - ;; - "CHAR:s" | "CHAR:S") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - elif [[ "$has_metadata" == "true" ]]; then - case "$sort_mode" in - date) sort_mode="name" ;; - name) sort_mode="size" ;; - size) sort_mode="date" ;; - esac - rebuild_view - need_full_redraw=true - fi - ;; - "CHAR:j") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - elif [[ ${#view_indices[@]} -gt 0 ]]; then - local absolute_index=$((top_index + cursor_pos)) - local last_index=$((${#view_indices[@]} - 1)) - if [[ $absolute_index -lt $last_index ]]; then - local visible_count=$((${#view_indices[@]} - top_index)) - [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page - if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then - ((cursor_pos++)) - elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then - ((top_index++)) - fi - need_full_redraw=true - fi - fi - ;; - "CHAR:k") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - elif [[ ${#view_indices[@]} -gt 0 ]]; then - if [[ $cursor_pos -gt 0 ]]; then - ((cursor_pos--)) - need_full_redraw=true - elif [[ $top_index -gt 0 ]]; then - ((top_index--)) - need_full_redraw=true - fi - fi - ;; - "CHAR:o" | "CHAR:O") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - elif [[ "$has_metadata" == "true" ]]; then - if [[ "$sort_reverse" == "true" ]]; then - sort_reverse="false" - else - sort_reverse="true" - fi - rebuild_view - need_full_redraw=true - fi - ;; - "CHAR:/" | "CHAR:?") - if [[ -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - unset MOLE_READ_KEY_FORCE_CHAR - else - export MOLE_READ_KEY_FORCE_CHAR=1 - fi - need_full_redraw=true - ;; - "DELETE") - if [[ -n "$filter_text" ]]; then - filter_text="${filter_text%?}" - filter_text_lower="${filter_text_lower%?}" - if [[ -z "$filter_text" ]]; then + filter_text="" filter_text_lower="" unset MOLE_READ_KEY_FORCE_CHAR + rebuild_view + cursor_pos=0 + top_index=0 + need_full_redraw=true + else + cleanup + return 1 fi - rebuild_view - cursor_pos=0 - top_index=0 - need_full_redraw=true - fi - ;; - "CLEAR_LINE") - if [[ -n "$filter_text" ]]; then - filter_text="" - filter_text_lower="" - rebuild_view - cursor_pos=0 - top_index=0 - need_full_redraw=true - fi - ;; - "CHAR:"*) - handle_filter_char "${key#CHAR:}" || true - ;; - "ENTER") - # 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 + ;; + "UP") + if [[ ${#view_indices[@]} -eq 0 ]]; then + : + elif [[ $cursor_pos -gt 0 ]]; then + local old_cursor=$cursor_pos + ((cursor_pos--)) + local new_cursor=$cursor_pos - # 2. If nothing selected, auto-select current item - if [[ $has_selection == false ]]; then + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + draw_header + fi + + local old_row=$((old_cursor + 3)) + local new_row=$((new_cursor + 3)) + + printf "\033[%d;1H" "$old_row" >&2 + render_item "$old_cursor" false + printf "\033[%d;1H" "$new_row" >&2 + render_item "$new_cursor" true + + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + + prev_cursor_pos=$cursor_pos + continue + elif [[ $top_index -gt 0 ]]; then + ((top_index--)) + + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + draw_header + fi + + local start_idx=$top_index + local end_idx=$((top_index + items_per_page - 1)) + local visible_total=${#view_indices[@]} + [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) + + for ((i = start_idx; i <= end_idx; i++)); do + local row=$((i - start_idx + 3)) + printf "\033[%d;1H" "$row" >&2 + local is_current=false + [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true + render_item $((i - start_idx)) $is_current + done + + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + + prev_cursor_pos=$cursor_pos + prev_top_index=$top_index + continue + fi + ;; + "DOWN") + if [[ ${#view_indices[@]} -eq 0 ]]; then + : + else + local absolute_index=$((top_index + cursor_pos)) + local last_index=$((${#view_indices[@]} - 1)) + if [[ $absolute_index -lt $last_index ]]; then + local visible_count=$((${#view_indices[@]} - top_index)) + [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page + + if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then + local old_cursor=$cursor_pos + ((cursor_pos++)) + local new_cursor=$cursor_pos + + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + draw_header + fi + + local old_row=$((old_cursor + 3)) + local new_row=$((new_cursor + 3)) + + printf "\033[%d;1H" "$old_row" >&2 + render_item "$old_cursor" false + printf "\033[%d;1H" "$new_row" >&2 + render_item "$new_cursor" true + + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + + prev_cursor_pos=$cursor_pos + continue + elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then + ((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)) + fi + + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + draw_header + fi + + local start_idx=$top_index + local end_idx=$((top_index + items_per_page - 1)) + local visible_total=${#view_indices[@]} + [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) + + for ((i = start_idx; i <= end_idx; i++)); do + local row=$((i - start_idx + 3)) + printf "\033[%d;1H" "$row" >&2 + local is_current=false + [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true + render_item $((i - start_idx)) $is_current + done + + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + + prev_cursor_pos=$cursor_pos + prev_top_index=$top_index + continue + fi + fi + fi + ;; + "SPACE") local idx=$((top_index + cursor_pos)) if [[ $idx -lt ${#view_indices[@]} ]]; then local real="${view_indices[idx]}" - selected[real]=true - ((selected_count++)) + if [[ ${selected[real]} == true ]]; then + selected[real]=false + ((selected_count--)) + else + selected[real]=true + ((selected_count++)) + fi + + # Incremental update: only redraw header (for count) and current row + # Header is at row 1 + printf "\033[1;1H\033[2K${PURPLE_BOLD}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2 + + # Redraw current item row (+3: row 1=header, row 2=blank, row 3=first item) + local item_row=$((cursor_pos + 3)) + printf "\033[%d;1H" "$item_row" >&2 + render_item "$cursor_pos" true + + # Move cursor to footer to avoid visual artifacts (items + header + 2 blanks) + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + + continue # Skip full redraw 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 - selected_indices+=("$i") + ;; + "CHAR:s" | "CHAR:S") + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ "$has_metadata" == "true" ]]; then + case "$sort_mode" in + date) sort_mode="name" ;; + name) sort_mode="size" ;; + size) sort_mode="date" ;; + esac + rebuild_view + need_full_redraw=true fi - done + ;; + "CHAR:j") + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ ${#view_indices[@]} -gt 0 ]]; then + local absolute_index=$((top_index + cursor_pos)) + local last_index=$((${#view_indices[@]} - 1)) + if [[ $absolute_index -lt $last_index ]]; then + local visible_count=$((${#view_indices[@]} - top_index)) + [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page + if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then + ((cursor_pos++)) + elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then + ((top_index++)) + fi + need_full_redraw=true + fi + fi + ;; + "CHAR:k") + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ ${#view_indices[@]} -gt 0 ]]; then + if [[ $cursor_pos -gt 0 ]]; then + ((cursor_pos--)) + need_full_redraw=true + elif [[ $top_index -gt 0 ]]; then + ((top_index--)) + need_full_redraw=true + fi + fi + ;; + "CHAR:o" | "CHAR:O") + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ "$has_metadata" == "true" ]]; then + if [[ "$sort_reverse" == "true" ]]; then + sort_reverse="false" + else + sort_reverse="true" + fi + rebuild_view + need_full_redraw=true + fi + ;; + "CHAR:/" | "CHAR:?") + if [[ -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + unset MOLE_READ_KEY_FORCE_CHAR + else + export MOLE_READ_KEY_FORCE_CHAR=1 + fi + need_full_redraw=true + ;; + "DELETE") + if [[ -n "$filter_text" ]]; then + filter_text="${filter_text%?}" + filter_text_lower="${filter_text_lower%?}" + if [[ -z "$filter_text" ]]; then + filter_text_lower="" + unset MOLE_READ_KEY_FORCE_CHAR + fi + rebuild_view + cursor_pos=0 + top_index=0 + need_full_redraw=true + fi + ;; + "CLEAR_LINE") + if [[ -n "$filter_text" ]]; then + filter_text="" + filter_text_lower="" + rebuild_view + cursor_pos=0 + top_index=0 + need_full_redraw=true + fi + ;; + "CHAR:"*) + handle_filter_char "${key#CHAR:}" || true + ;; + "ENTER") + # 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 - local final_result="" - if [[ ${#selected_indices[@]} -gt 0 ]]; then - local IFS=',' - final_result="${selected_indices[*]}" - fi + # 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 + ((selected_count++)) + fi + fi - trap - EXIT INT TERM - MOLE_SELECTION_RESULT="$final_result" - export MOLE_MENU_SORT_MODE="${sort_mode:-name}" - export MOLE_MENU_SORT_REVERSE="${sort_reverse:-false}" - restore_terminal - return 0 - ;; + # 3. Confirm and exit with current selections + local -a selected_indices=() + for ((i = 0; i < total_items; i++)); do + if [[ ${selected[i]} == true ]]; then + selected_indices+=("$i") + fi + done + + local final_result="" + if [[ ${#selected_indices[@]} -gt 0 ]]; then + local IFS=',' + final_result="${selected_indices[*]}" + fi + + trap - EXIT INT TERM + MOLE_SELECTION_RESULT="$final_result" + export MOLE_MENU_SORT_MODE="${sort_mode:-name}" + export MOLE_MENU_SORT_REVERSE="${sort_reverse:-false}" + restore_terminal + return 0 + ;; esac # Drain any accumulated input after processing (e.g., mouse wheel events) From a2afb786af95eec490b594a9dc6baf7e905e9ef0 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 7 Feb 2026 10:31:15 +0800 Subject: [PATCH 57/72] fix(ui): standardize spacing in terminal control functions and improve readability --- lib/ui/menu_paginated.sh | 608 +++++++++++++++++++-------------------- 1 file changed, 304 insertions(+), 304 deletions(-) diff --git a/lib/ui/menu_paginated.sh b/lib/ui/menu_paginated.sh index e806edc..52e08d7 100755 --- a/lib/ui/menu_paginated.sh +++ b/lib/ui/menu_paginated.sh @@ -5,13 +5,13 @@ set -euo pipefail # Terminal control functions enter_alt_screen() { - if command -v tput >/dev/null 2>&1 && [[ -t 1 ]]; then - tput smcup 2>/dev/null || true + 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 + if command -v tput > /dev/null 2>&1 && [[ -t 1 ]]; then + tput rmcup 2> /dev/null || true fi } @@ -22,13 +22,13 @@ _pm_get_terminal_height() { # Try stty size first (most reliable, real-time) # Use /dev/null | awk '{print $1}') + height=$(stty size < /dev/tty 2> /dev/null | awk '{print $1}') fi # Fallback to tput if [[ -z "$height" || $height -le 0 ]]; then - if command -v tput >/dev/null 2>&1; then - height=$(tput lines 2>/dev/null || echo "24") + if command -v tput > /dev/null 2>&1; then + height=$(tput lines 2> /dev/null || echo "24") else height=24 fi @@ -110,7 +110,7 @@ paginated_multi_select() { has_metadata="true" fi if [[ -n "${MOLE_MENU_FILTER_NAMES:-}" ]]; then - while IFS= read -r v; do filter_names+=("$v"); done <<<"$MOLE_MENU_FILTER_NAMES" + while IFS= read -r v; do filter_names+=("$v"); done <<< "$MOLE_MENU_FILTER_NAMES" has_filter_names="true" fi @@ -149,7 +149,7 @@ paginated_multi_select() { if [[ -n "${MOLE_PRESELECTED_INDICES:-}" ]]; then local cleaned_preselect="${MOLE_PRESELECTED_INDICES//[[:space:]]/}" local -a initial_indices=() - IFS=',' read -ra initial_indices <<<"$cleaned_preselect" + 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 # Only count if not already selected (handles duplicates) @@ -163,16 +163,16 @@ paginated_multi_select() { # 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 "") + 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 + 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 + stty sane 2> /dev/null || stty echo icanon 2> /dev/null || true fi if [[ "${external_alt_screen:-false}" == false ]]; then leave_alt_screen @@ -198,7 +198,7 @@ paginated_multi_select() { trap handle_interrupt INT TERM # Setup terminal - preserve interrupt character - stty -echo -icanon intr ^C 2>/dev/null || true + 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 @@ -219,7 +219,7 @@ paginated_multi_select() { local -a segs=("$@") local cols="${COLUMNS:-}" - [[ -z "$cols" ]] && cols=$(tput cols 2>/dev/null || echo 80) + [[ -z "$cols" ]] && cols=$(tput cols 2> /dev/null || echo 80) [[ "$cols" =~ ^[0-9]+$ ]] || cols=80 _strip_ansi_len() { @@ -286,23 +286,23 @@ paginated_multi_select() { fi local tmpfile - tmpfile=$(mktemp 2>/dev/null) || tmpfile="" + tmpfile=$(mktemp 2> /dev/null) || tmpfile="" if [[ -n "$tmpfile" ]]; then local k id for id in "${orig_indices[@]}"; do case "$sort_mode" in - date) k="${epochs[id]:-0}" ;; - size) k="${sizekb[id]:-0}" ;; - name | *) k="${items[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" + printf "%s\t%s\n" "$k" "$id" >> "$tmpfile" done sorted_indices_cache=() while IFS=$'\t' read -r _key _id; do [[ -z "$_id" ]] && continue sorted_indices_cache+=("$_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 @@ -482,9 +482,9 @@ paginated_multi_select() { # Build sort status local sort_label="" case "$sort_mode" in - date) sort_label="Date" ;; - name) sort_label="Name" ;; - size) sort_label="Size" ;; + date) sort_label="Date" ;; + name) sort_label="Name" ;; + size) sort_label="Size" ;; esac local sort_status="${sort_label}" @@ -518,7 +518,7 @@ paginated_multi_select() { elif [[ "$has_metadata" == "true" ]]; then # With metadata: show sort controls local term_width="${COLUMNS:-}" - [[ -z "$term_width" ]] && term_width=$(tput cols 2>/dev/null || echo 80) + [[ -z "$term_width" ]] && term_width=$(tput cols 2> /dev/null || echo 80) [[ "$term_width" =~ ^[0-9]+$ ]] || term_width=80 # Full controls @@ -576,297 +576,297 @@ paginated_multi_select() { key=$(read_key) case "$key" in - "QUIT") - if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - filter_text="" - filter_text_lower="" - unset MOLE_READ_KEY_FORCE_CHAR - rebuild_view - cursor_pos=0 - top_index=0 - need_full_redraw=true - else - cleanup - return 1 - fi - ;; - "UP") - if [[ ${#view_indices[@]} -eq 0 ]]; then - : - elif [[ $cursor_pos -gt 0 ]]; then - local old_cursor=$cursor_pos - ((cursor_pos--)) - local new_cursor=$cursor_pos - + "QUIT") if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - draw_header - fi - - local old_row=$((old_cursor + 3)) - local new_row=$((new_cursor + 3)) - - printf "\033[%d;1H" "$old_row" >&2 - render_item "$old_cursor" false - printf "\033[%d;1H" "$new_row" >&2 - render_item "$new_cursor" true - - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - prev_cursor_pos=$cursor_pos - continue - elif [[ $top_index -gt 0 ]]; then - ((top_index--)) - - if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - draw_header - fi - - local start_idx=$top_index - local end_idx=$((top_index + items_per_page - 1)) - local visible_total=${#view_indices[@]} - [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) - - for ((i = start_idx; i <= end_idx; i++)); do - local row=$((i - start_idx + 3)) - printf "\033[%d;1H" "$row" >&2 - local is_current=false - [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true - render_item $((i - start_idx)) $is_current - done - - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - prev_cursor_pos=$cursor_pos - prev_top_index=$top_index - continue - fi - ;; - "DOWN") - if [[ ${#view_indices[@]} -eq 0 ]]; then - : - else - local absolute_index=$((top_index + cursor_pos)) - local last_index=$((${#view_indices[@]} - 1)) - if [[ $absolute_index -lt $last_index ]]; then - local visible_count=$((${#view_indices[@]} - top_index)) - [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page - - if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then - local old_cursor=$cursor_pos - ((cursor_pos++)) - local new_cursor=$cursor_pos - - if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - draw_header - fi - - local old_row=$((old_cursor + 3)) - local new_row=$((new_cursor + 3)) - - printf "\033[%d;1H" "$old_row" >&2 - render_item "$old_cursor" false - printf "\033[%d;1H" "$new_row" >&2 - render_item "$new_cursor" true - - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - prev_cursor_pos=$cursor_pos - continue - elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then - ((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)) - fi - - if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - draw_header - fi - - local start_idx=$top_index - local end_idx=$((top_index + items_per_page - 1)) - local visible_total=${#view_indices[@]} - [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) - - for ((i = start_idx; i <= end_idx; i++)); do - local row=$((i - start_idx + 3)) - printf "\033[%d;1H" "$row" >&2 - local is_current=false - [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true - render_item $((i - start_idx)) $is_current - done - - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - prev_cursor_pos=$cursor_pos - prev_top_index=$top_index - continue - fi - fi - fi - ;; - "SPACE") - local idx=$((top_index + cursor_pos)) - if [[ $idx -lt ${#view_indices[@]} ]]; then - local real="${view_indices[idx]}" - if [[ ${selected[real]} == true ]]; then - selected[real]=false - ((selected_count--)) - else - selected[real]=true - ((selected_count++)) - fi - - # Incremental update: only redraw header (for count) and current row - # Header is at row 1 - printf "\033[1;1H\033[2K${PURPLE_BOLD}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2 - - # Redraw current item row (+3: row 1=header, row 2=blank, row 3=first item) - local item_row=$((cursor_pos + 3)) - printf "\033[%d;1H" "$item_row" >&2 - render_item "$cursor_pos" true - - # Move cursor to footer to avoid visual artifacts (items + header + 2 blanks) - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - continue # Skip full redraw - fi - ;; - "CHAR:s" | "CHAR:S") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - elif [[ "$has_metadata" == "true" ]]; then - case "$sort_mode" in - date) sort_mode="name" ;; - name) sort_mode="size" ;; - size) sort_mode="date" ;; - esac - rebuild_view - need_full_redraw=true - fi - ;; - "CHAR:j") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - elif [[ ${#view_indices[@]} -gt 0 ]]; then - local absolute_index=$((top_index + cursor_pos)) - local last_index=$((${#view_indices[@]} - 1)) - if [[ $absolute_index -lt $last_index ]]; then - local visible_count=$((${#view_indices[@]} - top_index)) - [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page - if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then - ((cursor_pos++)) - elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then - ((top_index++)) - fi - need_full_redraw=true - fi - fi - ;; - "CHAR:k") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - elif [[ ${#view_indices[@]} -gt 0 ]]; then - if [[ $cursor_pos -gt 0 ]]; then - ((cursor_pos--)) - need_full_redraw=true - elif [[ $top_index -gt 0 ]]; then - ((top_index--)) - need_full_redraw=true - fi - fi - ;; - "CHAR:o" | "CHAR:O") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - elif [[ "$has_metadata" == "true" ]]; then - if [[ "$sort_reverse" == "true" ]]; then - sort_reverse="false" - else - sort_reverse="true" - fi - rebuild_view - need_full_redraw=true - fi - ;; - "CHAR:/" | "CHAR:?") - if [[ -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - unset MOLE_READ_KEY_FORCE_CHAR - else - export MOLE_READ_KEY_FORCE_CHAR=1 - fi - need_full_redraw=true - ;; - "DELETE") - if [[ -n "$filter_text" ]]; then - filter_text="${filter_text%?}" - filter_text_lower="${filter_text_lower%?}" - if [[ -z "$filter_text" ]]; then + filter_text="" filter_text_lower="" unset MOLE_READ_KEY_FORCE_CHAR + rebuild_view + cursor_pos=0 + top_index=0 + need_full_redraw=true + else + cleanup + return 1 fi - rebuild_view - cursor_pos=0 - top_index=0 - need_full_redraw=true - fi - ;; - "CLEAR_LINE") - if [[ -n "$filter_text" ]]; then - filter_text="" - filter_text_lower="" - rebuild_view - cursor_pos=0 - top_index=0 - need_full_redraw=true - fi - ;; - "CHAR:"*) - handle_filter_char "${key#CHAR:}" || true - ;; - "ENTER") - # 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 + ;; + "UP") + if [[ ${#view_indices[@]} -eq 0 ]]; then + : + elif [[ $cursor_pos -gt 0 ]]; then + local old_cursor=$cursor_pos + ((cursor_pos--)) + local new_cursor=$cursor_pos - # 2. If nothing selected, auto-select current item - if [[ $has_selection == false ]]; then + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + draw_header + fi + + local old_row=$((old_cursor + 3)) + local new_row=$((new_cursor + 3)) + + printf "\033[%d;1H" "$old_row" >&2 + render_item "$old_cursor" false + printf "\033[%d;1H" "$new_row" >&2 + render_item "$new_cursor" true + + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + + prev_cursor_pos=$cursor_pos + continue + elif [[ $top_index -gt 0 ]]; then + ((top_index--)) + + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + draw_header + fi + + local start_idx=$top_index + local end_idx=$((top_index + items_per_page - 1)) + local visible_total=${#view_indices[@]} + [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) + + for ((i = start_idx; i <= end_idx; i++)); do + local row=$((i - start_idx + 3)) + printf "\033[%d;1H" "$row" >&2 + local is_current=false + [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true + render_item $((i - start_idx)) $is_current + done + + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + + prev_cursor_pos=$cursor_pos + prev_top_index=$top_index + continue + fi + ;; + "DOWN") + if [[ ${#view_indices[@]} -eq 0 ]]; then + : + else + local absolute_index=$((top_index + cursor_pos)) + local last_index=$((${#view_indices[@]} - 1)) + if [[ $absolute_index -lt $last_index ]]; then + local visible_count=$((${#view_indices[@]} - top_index)) + [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page + + if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then + local old_cursor=$cursor_pos + ((cursor_pos++)) + local new_cursor=$cursor_pos + + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + draw_header + fi + + local old_row=$((old_cursor + 3)) + local new_row=$((new_cursor + 3)) + + printf "\033[%d;1H" "$old_row" >&2 + render_item "$old_cursor" false + printf "\033[%d;1H" "$new_row" >&2 + render_item "$new_cursor" true + + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + + prev_cursor_pos=$cursor_pos + continue + elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then + ((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)) + fi + + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + draw_header + fi + + local start_idx=$top_index + local end_idx=$((top_index + items_per_page - 1)) + local visible_total=${#view_indices[@]} + [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) + + for ((i = start_idx; i <= end_idx; i++)); do + local row=$((i - start_idx + 3)) + printf "\033[%d;1H" "$row" >&2 + local is_current=false + [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true + render_item $((i - start_idx)) $is_current + done + + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + + prev_cursor_pos=$cursor_pos + prev_top_index=$top_index + continue + fi + fi + fi + ;; + "SPACE") local idx=$((top_index + cursor_pos)) if [[ $idx -lt ${#view_indices[@]} ]]; then local real="${view_indices[idx]}" - selected[real]=true - ((selected_count++)) + if [[ ${selected[real]} == true ]]; then + selected[real]=false + ((selected_count--)) + else + selected[real]=true + ((selected_count++)) + fi + + # Incremental update: only redraw header (for count) and current row + # Header is at row 1 + printf "\033[1;1H\033[2K${PURPLE_BOLD}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2 + + # Redraw current item row (+3: row 1=header, row 2=blank, row 3=first item) + local item_row=$((cursor_pos + 3)) + printf "\033[%d;1H" "$item_row" >&2 + render_item "$cursor_pos" true + + # Move cursor to footer to avoid visual artifacts (items + header + 2 blanks) + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + + continue # Skip full redraw 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 - selected_indices+=("$i") + ;; + "CHAR:s" | "CHAR:S") + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ "$has_metadata" == "true" ]]; then + case "$sort_mode" in + date) sort_mode="name" ;; + name) sort_mode="size" ;; + size) sort_mode="date" ;; + esac + rebuild_view + need_full_redraw=true fi - done + ;; + "CHAR:j") + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ ${#view_indices[@]} -gt 0 ]]; then + local absolute_index=$((top_index + cursor_pos)) + local last_index=$((${#view_indices[@]} - 1)) + if [[ $absolute_index -lt $last_index ]]; then + local visible_count=$((${#view_indices[@]} - top_index)) + [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page + if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then + ((cursor_pos++)) + elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then + ((top_index++)) + fi + need_full_redraw=true + fi + fi + ;; + "CHAR:k") + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ ${#view_indices[@]} -gt 0 ]]; then + if [[ $cursor_pos -gt 0 ]]; then + ((cursor_pos--)) + need_full_redraw=true + elif [[ $top_index -gt 0 ]]; then + ((top_index--)) + need_full_redraw=true + fi + fi + ;; + "CHAR:o" | "CHAR:O") + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ "$has_metadata" == "true" ]]; then + if [[ "$sort_reverse" == "true" ]]; then + sort_reverse="false" + else + sort_reverse="true" + fi + rebuild_view + need_full_redraw=true + fi + ;; + "CHAR:/" | "CHAR:?") + if [[ -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + unset MOLE_READ_KEY_FORCE_CHAR + else + export MOLE_READ_KEY_FORCE_CHAR=1 + fi + need_full_redraw=true + ;; + "DELETE") + if [[ -n "$filter_text" ]]; then + filter_text="${filter_text%?}" + filter_text_lower="${filter_text_lower%?}" + if [[ -z "$filter_text" ]]; then + filter_text_lower="" + unset MOLE_READ_KEY_FORCE_CHAR + fi + rebuild_view + cursor_pos=0 + top_index=0 + need_full_redraw=true + fi + ;; + "CLEAR_LINE") + if [[ -n "$filter_text" ]]; then + filter_text="" + filter_text_lower="" + rebuild_view + cursor_pos=0 + top_index=0 + need_full_redraw=true + fi + ;; + "CHAR:"*) + handle_filter_char "${key#CHAR:}" || true + ;; + "ENTER") + # 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 - local final_result="" - if [[ ${#selected_indices[@]} -gt 0 ]]; then - local IFS=',' - final_result="${selected_indices[*]}" - fi + # 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 + ((selected_count++)) + fi + fi - trap - EXIT INT TERM - MOLE_SELECTION_RESULT="$final_result" - export MOLE_MENU_SORT_MODE="${sort_mode:-name}" - export MOLE_MENU_SORT_REVERSE="${sort_reverse:-false}" - restore_terminal - return 0 - ;; + # 3. Confirm and exit with current selections + local -a selected_indices=() + for ((i = 0; i < total_items; i++)); do + if [[ ${selected[i]} == true ]]; then + selected_indices+=("$i") + fi + done + + local final_result="" + if [[ ${#selected_indices[@]} -gt 0 ]]; then + local IFS=',' + final_result="${selected_indices[*]}" + fi + + trap - EXIT INT TERM + MOLE_SELECTION_RESULT="$final_result" + export MOLE_MENU_SORT_MODE="${sort_mode:-name}" + export MOLE_MENU_SORT_REVERSE="${sort_reverse:-false}" + restore_terminal + return 0 + ;; esac # Drain any accumulated input after processing (e.g., mouse wheel events) From 500ab2f5683a68a376e0954939326cf8aee59ebe Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 7 Feb 2026 10:38:17 +0800 Subject: [PATCH 58/72] fix(uninstall): detect Zed HTTPStorages channel variants (#422) --- lib/core/app_protection.sh | 8 ++++++++ tests/uninstall_naming_variants.bats | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index cc54844..63247d4 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -997,6 +997,14 @@ find_app_files() { ) fi + # Issue #422: Zed channel builds can leave data under another channel bundle id. + # Example: uninstalling dev.zed.Zed-Nightly should also detect dev.zed.Zed-Preview leftovers. + if [[ "$bundle_id" =~ ^dev\.zed\.Zed- ]] && [[ -d "$HOME/Library/HTTPStorages" ]]; then + while IFS= read -r -d '' zed_http_storage; do + files_to_clean+=("$zed_http_storage") + done < <(command find "$HOME/Library/HTTPStorages" -maxdepth 1 -name "dev.zed.Zed-*" -print0 2> /dev/null) + fi + # Process standard patterns for p in "${user_patterns[@]}"; do local expanded_path="${p/#\~/$HOME}" diff --git a/tests/uninstall_naming_variants.bats b/tests/uninstall_naming_variants.bats index efd687f..1b25560 100644 --- a/tests/uninstall_naming_variants.bats +++ b/tests/uninstall_naming_variants.bats @@ -60,6 +60,18 @@ setup() { [[ "$result" =~ "Library/Application Support/Zed" ]] } +@test "find_app_files detects Zed channel variants in HTTPStorages only" { + mkdir -p "$HOME/Library/HTTPStorages/dev.zed.Zed-Preview" + mkdir -p "$HOME/Library/Application Support/Firefox/Profiles/default/storage/default/https+++zed.dev" + echo "test" > "$HOME/Library/HTTPStorages/dev.zed.Zed-Preview/data" + echo "test" > "$HOME/Library/Application Support/Firefox/Profiles/default/storage/default/https+++zed.dev/data" + + result=$(find_app_files "dev.zed.Zed-Nightly" "Zed Nightly") + + [[ "$result" =~ "Library/HTTPStorages/dev.zed.Zed-Preview" ]] + [[ ! "$result" =~ "storage/default/https\+\+\+zed\.dev" ]] +} + @test "find_app_files detects multiple naming variants simultaneously" { mkdir -p "$HOME/.config/maestro-studio" mkdir -p "$HOME/Library/Application Support/MaestroStudio" From 5cdfcf2479c51b4e9359cd912409751446b2fd68 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 7 Feb 2026 10:44:10 +0800 Subject: [PATCH 59/72] fix(uninstall): detect Maestro Studio .mobiledev and add regression test (#421) --- lib/core/app_protection.sh | 5 +++++ tests/uninstall_naming_variants.bats | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index 63247d4..bb5fcde 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -1106,6 +1106,11 @@ find_app_files() { [[ "$bundle_id" =~ microsoft.*vscode ]] && [[ -d ~/.vscode ]] && files_to_clean+=("$HOME/.vscode") [[ "$app_name" =~ Docker ]] && [[ -d ~/.docker ]] && files_to_clean+=("$HOME/.docker") + # 6.1 Maestro Studio + if [[ "$bundle_id" == "com.maestro.studio" ]] || [[ "$lowercase_name" =~ maestro[[:space:]]*studio ]]; then + [[ -d ~/.mobiledev ]] && files_to_clean+=("$HOME/.mobiledev") + fi + # 7. Raycast if [[ "$bundle_id" == "com.raycast.macos" ]]; then # Standard user directories diff --git a/tests/uninstall_naming_variants.bats b/tests/uninstall_naming_variants.bats index 1b25560..9344dad 100644 --- a/tests/uninstall_naming_variants.bats +++ b/tests/uninstall_naming_variants.bats @@ -48,6 +48,15 @@ setup() { [[ "$result" =~ "Library/Application Support/MaestroStudio" ]] } +@test "find_app_files detects Maestro Studio auth directory (.mobiledev)" { + mkdir -p "$HOME/.mobiledev" + echo "token" > "$HOME/.mobiledev/authtoken" + + result=$(find_app_files "com.maestro.studio" "Maestro Studio") + + [[ "$result" =~ .mobiledev ]] +} + @test "find_app_files extracts base name from version suffix (Zed Nightly -> zed)" { mkdir -p "$HOME/.config/zed" mkdir -p "$HOME/Library/Application Support/Zed" From 95b3818da8fcce06f7716bb12f7c63504d9731fb Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 7 Feb 2026 11:01:00 +0800 Subject: [PATCH 60/72] fix(analyze): fix scan deadlock with non-blocking fallback and add regression test (#419) --- cmd/analyze/analyze_test.go | 38 +++++++++++++++++++++++++++++++++++++ cmd/analyze/scanner.go | 20 ++++++++++++------- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/cmd/analyze/analyze_test.go b/cmd/analyze/analyze_test.go index 243b959..6618c01 100644 --- a/cmd/analyze/analyze_test.go +++ b/cmd/analyze/analyze_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/gob" + "fmt" "os" "path/filepath" "strings" @@ -448,3 +449,40 @@ func TestScanPathPermissionError(t *testing.T) { t.Logf("unexpected error type: %v", err) } } + +func TestCalculateDirSizeFastHighFanoutCompletes(t *testing.T) { + root := t.TempDir() + + // Reproduce high fan-out nested directory pattern that previously risked semaphore deadlock. + const fanout = 256 + for i := 0; i < fanout; i++ { + nested := filepath.Join(root, fmt.Sprintf("dir-%03d", i), "nested") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatalf("create nested dir: %v", err) + } + if err := os.WriteFile(filepath.Join(nested, "data.bin"), []byte("x"), 0o644); err != nil { + t.Fatalf("write nested file: %v", err) + } + } + + var files, dirs, bytes int64 + current := &atomic.Value{} + current.Store("") + + done := make(chan int64, 1) + go func() { + done <- calculateDirSizeFast(root, &files, &dirs, &bytes, current) + }() + + select { + case total := <-done: + if total <= 0 { + t.Fatalf("expected positive total size, got %d", total) + } + if got := atomic.LoadInt64(&files); got < fanout { + t.Fatalf("expected at least %d files scanned, got %d", fanout, got) + } + case <-time.After(5 * time.Second): + t.Fatalf("calculateDirSizeFast did not complete under high fan-out") + } +} diff --git a/cmd/analyze/scanner.go b/cmd/analyze/scanner.go index 9ab71ad..d7f5e97 100644 --- a/cmd/analyze/scanner.go +++ b/cmd/analyze/scanner.go @@ -351,14 +351,20 @@ func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned * for _, entry := range entries { if entry.IsDir() { subDir := filepath.Join(dirPath, entry.Name()) - sem <- struct{}{} - wg.Add(1) - go func(p string) { - defer wg.Done() - defer func() { <-sem }() - walk(p) - }(subDir) atomic.AddInt64(dirsScanned, 1) + + select { + case sem <- struct{}{}: + wg.Add(1) + go func(p string) { + defer wg.Done() + defer func() { <-sem }() + walk(p) + }(subDir) + default: + // Fallback to synchronous traversal to avoid semaphore deadlock under high fan-out. + walk(subDir) + } } else { info, err := entry.Info() if err == nil { From 6f8f16ce48ce93465413be9094f8d1804ae178d2 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 7 Feb 2026 11:17:07 +0800 Subject: [PATCH 61/72] optimize Xcode simulator cleanup feedback/count (#418) --- lib/clean/dev.sh | 68 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/lib/clean/dev.sh b/lib/clean/dev.sh index 3306164..c57a218 100644 --- a/lib/clean/dev.sh +++ b/lib/clean/dev.sh @@ -6,7 +6,7 @@ clean_tool_cache() { local description="$1" shift if [[ "$DRY_RUN" != "true" ]]; then - if "$@" > /dev/null 2>&1; then + if "$@" >/dev/null 2>&1; then echo -e " ${GREEN}${ICON_SUCCESS}${NC} $description" fi else @@ -16,18 +16,18 @@ clean_tool_cache() { } # npm/pnpm/yarn/bun caches. clean_dev_npm() { - if command -v npm > /dev/null 2>&1; then + if command -v npm >/dev/null 2>&1; then clean_tool_cache "npm cache" npm cache clean --force note_activity fi # Clean pnpm store cache local pnpm_default_store=~/Library/pnpm/store # Check if pnpm is actually usable (not just Corepack shim) - if command -v pnpm > /dev/null 2>&1 && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 pnpm --version > /dev/null 2>&1; then + if command -v pnpm >/dev/null 2>&1 && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 pnpm --version >/dev/null 2>&1; then COREPACK_ENABLE_DOWNLOAD_PROMPT=0 clean_tool_cache "pnpm cache" pnpm store prune local pnpm_store_path start_section_spinner "Checking store path..." - pnpm_store_path=$(COREPACK_ENABLE_DOWNLOAD_PROMPT=0 run_with_timeout 2 pnpm store path 2> /dev/null) || pnpm_store_path="" + pnpm_store_path=$(COREPACK_ENABLE_DOWNLOAD_PROMPT=0 run_with_timeout 2 pnpm store path 2>/dev/null) || pnpm_store_path="" stop_section_spinner if [[ -n "$pnpm_store_path" && "$pnpm_store_path" != "$pnpm_default_store" ]]; then safe_clean "$pnpm_default_store"/* "Orphaned pnpm store" @@ -44,7 +44,7 @@ clean_dev_npm() { } # Python/pip ecosystem caches. clean_dev_python() { - if command -v pip3 > /dev/null 2>&1; then + if command -v pip3 >/dev/null 2>&1; then clean_tool_cache "pip cache" bash -c 'pip3 cache purge > /dev/null 2>&1 || true' note_activity fi @@ -64,7 +64,7 @@ clean_dev_python() { } # Go build/module caches. clean_dev_go() { - if command -v go > /dev/null 2>&1; then + if command -v go >/dev/null 2>&1; then clean_tool_cache "Go cache" bash -c 'go clean -modcache > /dev/null 2>&1 || true; go clean -cache > /dev/null 2>&1 || true' note_activity fi @@ -89,7 +89,7 @@ check_multiple_versions() { fi local count - count=$(find "$dir" -mindepth 1 -maxdepth 1 -type d 2> /dev/null | wc -l | tr -d ' ') + count=$(find "$dir" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l | tr -d ' ') if [[ "$count" -gt 1 ]]; then note_activity @@ -103,7 +103,7 @@ check_multiple_versions() { # Check for multiple Rust toolchains. check_rust_toolchains() { - command -v rustup > /dev/null 2>&1 || return 0 + command -v rustup >/dev/null 2>&1 || return 0 check_multiple_versions \ "$HOME/.rustup/toolchains" \ @@ -112,11 +112,11 @@ check_rust_toolchains() { } # Docker caches (guarded by daemon check). clean_dev_docker() { - if command -v docker > /dev/null 2>&1; then + if command -v docker >/dev/null 2>&1; then if [[ "$DRY_RUN" != "true" ]]; then start_section_spinner "Checking Docker daemon..." local docker_running=false - if run_with_timeout 3 docker info > /dev/null 2>&1; then + if run_with_timeout 3 docker info >/dev/null 2>&1; then docker_running=true fi stop_section_spinner @@ -134,7 +134,7 @@ clean_dev_docker() { } # Nix garbage collection. clean_dev_nix() { - if command -v nix-collect-garbage > /dev/null 2>&1; then + if command -v nix-collect-garbage >/dev/null 2>&1; then if [[ "$DRY_RUN" != "true" ]]; then clean_tool_cache "Nix garbage collection" nix-collect-garbage --delete-older-than 30d else @@ -176,17 +176,43 @@ check_android_ndk() { clean_dev_mobile() { check_android_ndk - if command -v xcrun > /dev/null 2>&1; then + if command -v xcrun >/dev/null 2>&1; then debug_log "Checking for unavailable Xcode simulators" + local unavailable_before=0 + local unavailable_after=0 + local removed_unavailable=0 + + unavailable_before=$(xcrun simctl list devices unavailable 2>/dev/null | command awk '/\(unavailable/ { count++ } END { print count+0 }' || echo "0") + [[ "$unavailable_before" =~ ^[0-9]+$ ]] || unavailable_before=0 + if [[ "$DRY_RUN" == "true" ]]; then - clean_tool_cache "Xcode unavailable simulators" xcrun simctl delete unavailable + if ((unavailable_before > 0)); then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Xcode unavailable simulators · would clean ${unavailable_before}" + else + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · already clean" + fi else start_section_spinner "Checking unavailable simulators..." - if xcrun simctl delete unavailable > /dev/null 2>&1; then + if xcrun simctl delete unavailable >/dev/null 2>&1; then stop_section_spinner - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators" + unavailable_after=$(xcrun simctl list devices unavailable 2>/dev/null | command awk '/\(unavailable/ { count++ } END { print count+0 }' || echo "0") + [[ "$unavailable_after" =~ ^[0-9]+$ ]] || unavailable_after=0 + + removed_unavailable=$((unavailable_before - unavailable_after)) + if ((removed_unavailable < 0)); then + removed_unavailable=0 + fi + + if ((unavailable_before == 0)); then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · already clean" + elif ((removed_unavailable > 0)); then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · removed ${removed_unavailable}" + else + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · cleanup completed" + fi else stop_section_spinner + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode unavailable simulators cleanup failed" fi fi note_activity @@ -244,7 +270,7 @@ clean_dev_jetbrains_toolbox() { local -a product_dirs=() while IFS= read -r -d '' product_dir; do product_dirs+=("$product_dir") - done < <(command find "$toolbox_root" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null) + done < <(command find "$toolbox_root" -mindepth 1 -maxdepth 1 -type d -print0 2>/dev/null) if [[ ${#product_dirs[@]} -eq 0 ]]; then _restore_whitelist @@ -257,7 +283,7 @@ clean_dev_jetbrains_toolbox() { local current_link="" local current_real="" if [[ -L "$channel_dir/current" ]]; then - current_link=$(readlink "$channel_dir/current" 2> /dev/null || true) + current_link=$(readlink "$channel_dir/current" 2>/dev/null || true) if [[ -n "$current_link" ]]; then if [[ "$current_link" == /* ]]; then current_real="$current_link" @@ -281,7 +307,7 @@ clean_dev_jetbrains_toolbox() { [[ ! "$name" =~ ^[0-9] ]] && continue version_dirs+=("$version_dir") - done < <(command find "$channel_dir" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null) + done < <(command find "$channel_dir" -mindepth 1 -maxdepth 1 -type d -print0 2>/dev/null) [[ ${#version_dirs[@]} -eq 0 ]] && continue @@ -292,7 +318,7 @@ clean_dev_jetbrains_toolbox() { done < <( for version_dir in "${version_dirs[@]}"; do local mtime - mtime=$(stat -f%m "$version_dir" 2> /dev/null || echo "0") + mtime=$(stat -f%m "$version_dir" 2>/dev/null || echo "0") printf '%s %s\n' "$mtime" "$version_dir" done | sort -rn ) @@ -312,7 +338,7 @@ clean_dev_jetbrains_toolbox() { note_activity ((idx++)) done - done < <(command find "$product_dir" -mindepth 1 -maxdepth 1 -type d -name "ch-*" -print0 2> /dev/null) + done < <(command find "$product_dir" -mindepth 1 -maxdepth 1 -type d -name "ch-*" -print0 2>/dev/null) done _restore_whitelist @@ -467,7 +493,7 @@ clean_developer_tools() { if [[ -d "$lock_dir" && -w "$lock_dir" ]]; then safe_clean "$lock_dir"/* "Homebrew lock files" elif [[ -d "$lock_dir" ]]; then - if find "$lock_dir" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then + if find "$lock_dir" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null | grep -q .; then debug_log "Skipping read-only Homebrew locks in $lock_dir" fi fi From fa8f7a80d910103c49ad7869815c7e38e5445142 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 7 Feb 2026 03:18:32 +0000 Subject: [PATCH 62/72] chore: auto format code --- lib/clean/dev.sh | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/lib/clean/dev.sh b/lib/clean/dev.sh index c57a218..aa6ec52 100644 --- a/lib/clean/dev.sh +++ b/lib/clean/dev.sh @@ -6,7 +6,7 @@ clean_tool_cache() { local description="$1" shift if [[ "$DRY_RUN" != "true" ]]; then - if "$@" >/dev/null 2>&1; then + if "$@" > /dev/null 2>&1; then echo -e " ${GREEN}${ICON_SUCCESS}${NC} $description" fi else @@ -16,18 +16,18 @@ clean_tool_cache() { } # npm/pnpm/yarn/bun caches. clean_dev_npm() { - if command -v npm >/dev/null 2>&1; then + if command -v npm > /dev/null 2>&1; then clean_tool_cache "npm cache" npm cache clean --force note_activity fi # Clean pnpm store cache local pnpm_default_store=~/Library/pnpm/store # Check if pnpm is actually usable (not just Corepack shim) - if command -v pnpm >/dev/null 2>&1 && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 pnpm --version >/dev/null 2>&1; then + if command -v pnpm > /dev/null 2>&1 && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 pnpm --version > /dev/null 2>&1; then COREPACK_ENABLE_DOWNLOAD_PROMPT=0 clean_tool_cache "pnpm cache" pnpm store prune local pnpm_store_path start_section_spinner "Checking store path..." - pnpm_store_path=$(COREPACK_ENABLE_DOWNLOAD_PROMPT=0 run_with_timeout 2 pnpm store path 2>/dev/null) || pnpm_store_path="" + pnpm_store_path=$(COREPACK_ENABLE_DOWNLOAD_PROMPT=0 run_with_timeout 2 pnpm store path 2> /dev/null) || pnpm_store_path="" stop_section_spinner if [[ -n "$pnpm_store_path" && "$pnpm_store_path" != "$pnpm_default_store" ]]; then safe_clean "$pnpm_default_store"/* "Orphaned pnpm store" @@ -44,7 +44,7 @@ clean_dev_npm() { } # Python/pip ecosystem caches. clean_dev_python() { - if command -v pip3 >/dev/null 2>&1; then + if command -v pip3 > /dev/null 2>&1; then clean_tool_cache "pip cache" bash -c 'pip3 cache purge > /dev/null 2>&1 || true' note_activity fi @@ -64,7 +64,7 @@ clean_dev_python() { } # Go build/module caches. clean_dev_go() { - if command -v go >/dev/null 2>&1; then + if command -v go > /dev/null 2>&1; then clean_tool_cache "Go cache" bash -c 'go clean -modcache > /dev/null 2>&1 || true; go clean -cache > /dev/null 2>&1 || true' note_activity fi @@ -89,7 +89,7 @@ check_multiple_versions() { fi local count - count=$(find "$dir" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l | tr -d ' ') + count=$(find "$dir" -mindepth 1 -maxdepth 1 -type d 2> /dev/null | wc -l | tr -d ' ') if [[ "$count" -gt 1 ]]; then note_activity @@ -103,7 +103,7 @@ check_multiple_versions() { # Check for multiple Rust toolchains. check_rust_toolchains() { - command -v rustup >/dev/null 2>&1 || return 0 + command -v rustup > /dev/null 2>&1 || return 0 check_multiple_versions \ "$HOME/.rustup/toolchains" \ @@ -112,11 +112,11 @@ check_rust_toolchains() { } # Docker caches (guarded by daemon check). clean_dev_docker() { - if command -v docker >/dev/null 2>&1; then + if command -v docker > /dev/null 2>&1; then if [[ "$DRY_RUN" != "true" ]]; then start_section_spinner "Checking Docker daemon..." local docker_running=false - if run_with_timeout 3 docker info >/dev/null 2>&1; then + if run_with_timeout 3 docker info > /dev/null 2>&1; then docker_running=true fi stop_section_spinner @@ -134,7 +134,7 @@ clean_dev_docker() { } # Nix garbage collection. clean_dev_nix() { - if command -v nix-collect-garbage >/dev/null 2>&1; then + if command -v nix-collect-garbage > /dev/null 2>&1; then if [[ "$DRY_RUN" != "true" ]]; then clean_tool_cache "Nix garbage collection" nix-collect-garbage --delete-older-than 30d else @@ -176,13 +176,13 @@ check_android_ndk() { clean_dev_mobile() { check_android_ndk - if command -v xcrun >/dev/null 2>&1; then + if command -v xcrun > /dev/null 2>&1; then debug_log "Checking for unavailable Xcode simulators" local unavailable_before=0 local unavailable_after=0 local removed_unavailable=0 - unavailable_before=$(xcrun simctl list devices unavailable 2>/dev/null | command awk '/\(unavailable/ { count++ } END { print count+0 }' || echo "0") + unavailable_before=$(xcrun simctl list devices unavailable 2> /dev/null | command awk '/\(unavailable/ { count++ } END { print count+0 }' || echo "0") [[ "$unavailable_before" =~ ^[0-9]+$ ]] || unavailable_before=0 if [[ "$DRY_RUN" == "true" ]]; then @@ -193,9 +193,9 @@ clean_dev_mobile() { fi else start_section_spinner "Checking unavailable simulators..." - if xcrun simctl delete unavailable >/dev/null 2>&1; then + if xcrun simctl delete unavailable > /dev/null 2>&1; then stop_section_spinner - unavailable_after=$(xcrun simctl list devices unavailable 2>/dev/null | command awk '/\(unavailable/ { count++ } END { print count+0 }' || echo "0") + unavailable_after=$(xcrun simctl list devices unavailable 2> /dev/null | command awk '/\(unavailable/ { count++ } END { print count+0 }' || echo "0") [[ "$unavailable_after" =~ ^[0-9]+$ ]] || unavailable_after=0 removed_unavailable=$((unavailable_before - unavailable_after)) @@ -270,7 +270,7 @@ clean_dev_jetbrains_toolbox() { local -a product_dirs=() while IFS= read -r -d '' product_dir; do product_dirs+=("$product_dir") - done < <(command find "$toolbox_root" -mindepth 1 -maxdepth 1 -type d -print0 2>/dev/null) + done < <(command find "$toolbox_root" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null) if [[ ${#product_dirs[@]} -eq 0 ]]; then _restore_whitelist @@ -283,7 +283,7 @@ clean_dev_jetbrains_toolbox() { local current_link="" local current_real="" if [[ -L "$channel_dir/current" ]]; then - current_link=$(readlink "$channel_dir/current" 2>/dev/null || true) + current_link=$(readlink "$channel_dir/current" 2> /dev/null || true) if [[ -n "$current_link" ]]; then if [[ "$current_link" == /* ]]; then current_real="$current_link" @@ -307,7 +307,7 @@ clean_dev_jetbrains_toolbox() { [[ ! "$name" =~ ^[0-9] ]] && continue version_dirs+=("$version_dir") - done < <(command find "$channel_dir" -mindepth 1 -maxdepth 1 -type d -print0 2>/dev/null) + done < <(command find "$channel_dir" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null) [[ ${#version_dirs[@]} -eq 0 ]] && continue @@ -318,7 +318,7 @@ clean_dev_jetbrains_toolbox() { done < <( for version_dir in "${version_dirs[@]}"; do local mtime - mtime=$(stat -f%m "$version_dir" 2>/dev/null || echo "0") + mtime=$(stat -f%m "$version_dir" 2> /dev/null || echo "0") printf '%s %s\n' "$mtime" "$version_dir" done | sort -rn ) @@ -338,7 +338,7 @@ clean_dev_jetbrains_toolbox() { note_activity ((idx++)) done - done < <(command find "$product_dir" -mindepth 1 -maxdepth 1 -type d -name "ch-*" -print0 2>/dev/null) + done < <(command find "$product_dir" -mindepth 1 -maxdepth 1 -type d -name "ch-*" -print0 2> /dev/null) done _restore_whitelist @@ -493,7 +493,7 @@ clean_developer_tools() { if [[ -d "$lock_dir" && -w "$lock_dir" ]]; then safe_clean "$lock_dir"/* "Homebrew lock files" elif [[ -d "$lock_dir" ]]; then - if find "$lock_dir" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null | grep -q .; then + if find "$lock_dir" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then debug_log "Skipping read-only Homebrew locks in $lock_dir" fi fi From 361d0dda05f8e3fafd5016dbf782d13a739188ce Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 7 Feb 2026 11:21:37 +0800 Subject: [PATCH 63/72] fix: P1/P2 issues in file ops, menu state, and logging - Fix safe_sudo_remove early exit on error (P1) - Fix menu filter state leakage in paginated menu (P2) - Fix cleanup of MOLE_MENU_FILTER_NAMES in app selector (P2) - Correct log_operation signature for memory dumps (P2) - Apply minor formatting fixes to dev cleanup module --- lib/clean/dev.sh | 42 ++++++++++++++++++++-------------------- lib/clean/system.sh | 2 +- lib/core/file_ops.sh | 3 +-- lib/ui/app_selector.sh | 2 +- lib/ui/menu_paginated.sh | 2 ++ 5 files changed, 26 insertions(+), 25 deletions(-) diff --git a/lib/clean/dev.sh b/lib/clean/dev.sh index c57a218..aa6ec52 100644 --- a/lib/clean/dev.sh +++ b/lib/clean/dev.sh @@ -6,7 +6,7 @@ clean_tool_cache() { local description="$1" shift if [[ "$DRY_RUN" != "true" ]]; then - if "$@" >/dev/null 2>&1; then + if "$@" > /dev/null 2>&1; then echo -e " ${GREEN}${ICON_SUCCESS}${NC} $description" fi else @@ -16,18 +16,18 @@ clean_tool_cache() { } # npm/pnpm/yarn/bun caches. clean_dev_npm() { - if command -v npm >/dev/null 2>&1; then + if command -v npm > /dev/null 2>&1; then clean_tool_cache "npm cache" npm cache clean --force note_activity fi # Clean pnpm store cache local pnpm_default_store=~/Library/pnpm/store # Check if pnpm is actually usable (not just Corepack shim) - if command -v pnpm >/dev/null 2>&1 && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 pnpm --version >/dev/null 2>&1; then + if command -v pnpm > /dev/null 2>&1 && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 pnpm --version > /dev/null 2>&1; then COREPACK_ENABLE_DOWNLOAD_PROMPT=0 clean_tool_cache "pnpm cache" pnpm store prune local pnpm_store_path start_section_spinner "Checking store path..." - pnpm_store_path=$(COREPACK_ENABLE_DOWNLOAD_PROMPT=0 run_with_timeout 2 pnpm store path 2>/dev/null) || pnpm_store_path="" + pnpm_store_path=$(COREPACK_ENABLE_DOWNLOAD_PROMPT=0 run_with_timeout 2 pnpm store path 2> /dev/null) || pnpm_store_path="" stop_section_spinner if [[ -n "$pnpm_store_path" && "$pnpm_store_path" != "$pnpm_default_store" ]]; then safe_clean "$pnpm_default_store"/* "Orphaned pnpm store" @@ -44,7 +44,7 @@ clean_dev_npm() { } # Python/pip ecosystem caches. clean_dev_python() { - if command -v pip3 >/dev/null 2>&1; then + if command -v pip3 > /dev/null 2>&1; then clean_tool_cache "pip cache" bash -c 'pip3 cache purge > /dev/null 2>&1 || true' note_activity fi @@ -64,7 +64,7 @@ clean_dev_python() { } # Go build/module caches. clean_dev_go() { - if command -v go >/dev/null 2>&1; then + if command -v go > /dev/null 2>&1; then clean_tool_cache "Go cache" bash -c 'go clean -modcache > /dev/null 2>&1 || true; go clean -cache > /dev/null 2>&1 || true' note_activity fi @@ -89,7 +89,7 @@ check_multiple_versions() { fi local count - count=$(find "$dir" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l | tr -d ' ') + count=$(find "$dir" -mindepth 1 -maxdepth 1 -type d 2> /dev/null | wc -l | tr -d ' ') if [[ "$count" -gt 1 ]]; then note_activity @@ -103,7 +103,7 @@ check_multiple_versions() { # Check for multiple Rust toolchains. check_rust_toolchains() { - command -v rustup >/dev/null 2>&1 || return 0 + command -v rustup > /dev/null 2>&1 || return 0 check_multiple_versions \ "$HOME/.rustup/toolchains" \ @@ -112,11 +112,11 @@ check_rust_toolchains() { } # Docker caches (guarded by daemon check). clean_dev_docker() { - if command -v docker >/dev/null 2>&1; then + if command -v docker > /dev/null 2>&1; then if [[ "$DRY_RUN" != "true" ]]; then start_section_spinner "Checking Docker daemon..." local docker_running=false - if run_with_timeout 3 docker info >/dev/null 2>&1; then + if run_with_timeout 3 docker info > /dev/null 2>&1; then docker_running=true fi stop_section_spinner @@ -134,7 +134,7 @@ clean_dev_docker() { } # Nix garbage collection. clean_dev_nix() { - if command -v nix-collect-garbage >/dev/null 2>&1; then + if command -v nix-collect-garbage > /dev/null 2>&1; then if [[ "$DRY_RUN" != "true" ]]; then clean_tool_cache "Nix garbage collection" nix-collect-garbage --delete-older-than 30d else @@ -176,13 +176,13 @@ check_android_ndk() { clean_dev_mobile() { check_android_ndk - if command -v xcrun >/dev/null 2>&1; then + if command -v xcrun > /dev/null 2>&1; then debug_log "Checking for unavailable Xcode simulators" local unavailable_before=0 local unavailable_after=0 local removed_unavailable=0 - unavailable_before=$(xcrun simctl list devices unavailable 2>/dev/null | command awk '/\(unavailable/ { count++ } END { print count+0 }' || echo "0") + unavailable_before=$(xcrun simctl list devices unavailable 2> /dev/null | command awk '/\(unavailable/ { count++ } END { print count+0 }' || echo "0") [[ "$unavailable_before" =~ ^[0-9]+$ ]] || unavailable_before=0 if [[ "$DRY_RUN" == "true" ]]; then @@ -193,9 +193,9 @@ clean_dev_mobile() { fi else start_section_spinner "Checking unavailable simulators..." - if xcrun simctl delete unavailable >/dev/null 2>&1; then + if xcrun simctl delete unavailable > /dev/null 2>&1; then stop_section_spinner - unavailable_after=$(xcrun simctl list devices unavailable 2>/dev/null | command awk '/\(unavailable/ { count++ } END { print count+0 }' || echo "0") + unavailable_after=$(xcrun simctl list devices unavailable 2> /dev/null | command awk '/\(unavailable/ { count++ } END { print count+0 }' || echo "0") [[ "$unavailable_after" =~ ^[0-9]+$ ]] || unavailable_after=0 removed_unavailable=$((unavailable_before - unavailable_after)) @@ -270,7 +270,7 @@ clean_dev_jetbrains_toolbox() { local -a product_dirs=() while IFS= read -r -d '' product_dir; do product_dirs+=("$product_dir") - done < <(command find "$toolbox_root" -mindepth 1 -maxdepth 1 -type d -print0 2>/dev/null) + done < <(command find "$toolbox_root" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null) if [[ ${#product_dirs[@]} -eq 0 ]]; then _restore_whitelist @@ -283,7 +283,7 @@ clean_dev_jetbrains_toolbox() { local current_link="" local current_real="" if [[ -L "$channel_dir/current" ]]; then - current_link=$(readlink "$channel_dir/current" 2>/dev/null || true) + current_link=$(readlink "$channel_dir/current" 2> /dev/null || true) if [[ -n "$current_link" ]]; then if [[ "$current_link" == /* ]]; then current_real="$current_link" @@ -307,7 +307,7 @@ clean_dev_jetbrains_toolbox() { [[ ! "$name" =~ ^[0-9] ]] && continue version_dirs+=("$version_dir") - done < <(command find "$channel_dir" -mindepth 1 -maxdepth 1 -type d -print0 2>/dev/null) + done < <(command find "$channel_dir" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null) [[ ${#version_dirs[@]} -eq 0 ]] && continue @@ -318,7 +318,7 @@ clean_dev_jetbrains_toolbox() { done < <( for version_dir in "${version_dirs[@]}"; do local mtime - mtime=$(stat -f%m "$version_dir" 2>/dev/null || echo "0") + mtime=$(stat -f%m "$version_dir" 2> /dev/null || echo "0") printf '%s %s\n' "$mtime" "$version_dir" done | sort -rn ) @@ -338,7 +338,7 @@ clean_dev_jetbrains_toolbox() { note_activity ((idx++)) done - done < <(command find "$product_dir" -mindepth 1 -maxdepth 1 -type d -name "ch-*" -print0 2>/dev/null) + done < <(command find "$product_dir" -mindepth 1 -maxdepth 1 -type d -name "ch-*" -print0 2> /dev/null) done _restore_whitelist @@ -493,7 +493,7 @@ clean_developer_tools() { if [[ -d "$lock_dir" && -w "$lock_dir" ]]; then safe_clean "$lock_dir"/* "Homebrew lock files" elif [[ -d "$lock_dir" ]]; then - if find "$lock_dir" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null | grep -q .; then + if find "$lock_dir" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then debug_log "Skipping read-only Homebrew locks in $lock_dir" fi fi diff --git a/lib/clean/system.sh b/lib/clean/system.sh index 13be78f..5530b90 100644 --- a/lib/clean/system.sh +++ b/lib/clean/system.sh @@ -179,7 +179,7 @@ clean_deep_system() { if oplog_enabled && [[ "$total_size_kb" -gt 0 ]]; then local size_human size_human=$(bytes_to_human "$((total_size_kb * 1024))") - log_operation "[clean] REMOVED $mem_reports_dir ($file_count files, $size_human)" + log_operation "clean" "REMOVED" "$mem_reports_dir" "$file_count files, $size_human" fi else log_info "[DRY-RUN] Would remove $file_count old memory exception reports ($total_size_kb KB)" diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index b2c3966..8dbbf5e 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -366,8 +366,7 @@ safe_sudo_remove() { local output local ret - output=$(sudo rm -rf "$path" 2>&1) # safe_remove - ret=$? + output=$(sudo rm -rf "$path" 2>&1) || ret=$? # safe_remove if [[ $ret -eq 0 ]]; then log_operation "${MOLE_CURRENT_COMMAND:-clean}" "REMOVED" "$path" "$size_human" diff --git a/lib/ui/app_selector.sh b/lib/ui/app_selector.sh index 3c15c72..b1db4fb 100755 --- a/lib/ui/app_selector.sh +++ b/lib/ui/app_selector.sh @@ -169,7 +169,7 @@ select_apps_for_uninstall() { local exit_code=$? # Clean env leakage for safety - unset MOLE_MENU_META_EPOCHS MOLE_MENU_META_SIZEKB + unset MOLE_MENU_META_EPOCHS MOLE_MENU_META_SIZEKB MOLE_MENU_FILTER_NAMES # leave MOLE_MENU_SORT_DEFAULT untouched if user set it globally if [[ $exit_code -ne 0 ]]; then diff --git a/lib/ui/menu_paginated.sh b/lib/ui/menu_paginated.sh index 52e08d7..3ef9adc 100755 --- a/lib/ui/menu_paginated.sh +++ b/lib/ui/menu_paginated.sh @@ -182,6 +182,7 @@ paginated_multi_select() { # Cleanup function cleanup() { trap - EXIT INT TERM + unset MOLE_READ_KEY_FORCE_CHAR export MOLE_MENU_SORT_MODE="${sort_mode:-name}" export MOLE_MENU_SORT_REVERSE="${sort_reverse:-false}" restore_terminal @@ -862,6 +863,7 @@ paginated_multi_select() { trap - EXIT INT TERM MOLE_SELECTION_RESULT="$final_result" + unset MOLE_READ_KEY_FORCE_CHAR export MOLE_MENU_SORT_MODE="${sort_mode:-name}" export MOLE_MENU_SORT_REVERSE="${sort_reverse:-false}" restore_terminal From 9b25260efe2d7beca56fd26be78bd53994639712 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 7 Feb 2026 12:32:02 +0800 Subject: [PATCH 64/72] test(uninstall): fix regex assertions for shellcheck SC2076 --- tests/uninstall_naming_variants.bats | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/uninstall_naming_variants.bats b/tests/uninstall_naming_variants.bats index 9344dad..eee48f9 100644 --- a/tests/uninstall_naming_variants.bats +++ b/tests/uninstall_naming_variants.bats @@ -77,8 +77,8 @@ setup() { result=$(find_app_files "dev.zed.Zed-Nightly" "Zed Nightly") - [[ "$result" =~ "Library/HTTPStorages/dev.zed.Zed-Preview" ]] - [[ ! "$result" =~ "storage/default/https\+\+\+zed\.dev" ]] + [[ "$result" =~ Library/HTTPStorages/dev\.zed\.Zed-Preview ]] + [[ ! "$result" =~ storage/default/https\+\+\+zed\.dev ]] } @test "find_app_files detects multiple naming variants simultaneously" { From 9ec0db924524edca4b793e47afae8a741845da05 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 7 Feb 2026 17:43:43 +0800 Subject: [PATCH 65/72] fix(analyze): reuse recent cache and refresh stale results --- cmd/analyze/analyze_test.go | 215 ++++++++++++++++++++++++++++++++++++ cmd/analyze/cache.go | 53 +++++++-- cmd/analyze/constants.go | 2 + cmd/analyze/main.go | 62 ++++++++++- 4 files changed, 317 insertions(+), 15 deletions(-) diff --git a/cmd/analyze/analyze_test.go b/cmd/analyze/analyze_test.go index 6618c01..25f3636 100644 --- a/cmd/analyze/analyze_test.go +++ b/cmd/analyze/analyze_test.go @@ -415,6 +415,221 @@ func TestLoadCacheExpiresWhenDirectoryChanges(t *testing.T) { } } +func TestLoadCacheReusesRecentEntryAfterDirectoryChanges(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + target := filepath.Join(home, "recent-change-target") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("create target: %v", err) + } + + result := scanResult{TotalSize: 5, TotalFiles: 1} + if err := saveCacheToDisk(target, result); err != nil { + t.Fatalf("saveCacheToDisk: %v", err) + } + + cachePath, err := getCachePath(target) + if err != nil { + t.Fatalf("getCachePath: %v", err) + } + + file, err := os.Open(cachePath) + if err != nil { + t.Fatalf("open cache: %v", err) + } + var entry cacheEntry + if err := gob.NewDecoder(file).Decode(&entry); err != nil { + t.Fatalf("decode cache: %v", err) + } + _ = file.Close() + + // Make cache entry look recently scanned, but older than mod time grace. + entry.ModTime = time.Now().Add(-2 * time.Hour) + entry.ScanTime = time.Now().Add(-1 * time.Hour) + + tmp := cachePath + ".tmp" + f, err := os.Create(tmp) + if err != nil { + t.Fatalf("create tmp cache: %v", err) + } + if err := gob.NewEncoder(f).Encode(&entry); err != nil { + t.Fatalf("encode tmp cache: %v", err) + } + _ = f.Close() + if err := os.Rename(tmp, cachePath); err != nil { + t.Fatalf("rename tmp cache: %v", err) + } + + if err := os.Chtimes(target, time.Now(), time.Now()); err != nil { + t.Fatalf("chtimes target: %v", err) + } + + if _, err := loadCacheFromDisk(target); err != nil { + t.Fatalf("expected recent cache to be reused, got error: %v", err) + } +} + +func TestLoadCacheExpiresWhenModifiedAndReuseWindowPassed(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + target := filepath.Join(home, "reuse-window-target") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("create target: %v", err) + } + + result := scanResult{TotalSize: 5, TotalFiles: 1} + if err := saveCacheToDisk(target, result); err != nil { + t.Fatalf("saveCacheToDisk: %v", err) + } + + cachePath, err := getCachePath(target) + if err != nil { + t.Fatalf("getCachePath: %v", err) + } + + file, err := os.Open(cachePath) + if err != nil { + t.Fatalf("open cache: %v", err) + } + var entry cacheEntry + if err := gob.NewDecoder(file).Decode(&entry); err != nil { + t.Fatalf("decode cache: %v", err) + } + _ = file.Close() + + // Within overall 7-day TTL but beyond reuse window. + entry.ModTime = time.Now().Add(-48 * time.Hour) + entry.ScanTime = time.Now().Add(-(cacheReuseWindow + time.Hour)) + + tmp := cachePath + ".tmp" + f, err := os.Create(tmp) + if err != nil { + t.Fatalf("create tmp cache: %v", err) + } + if err := gob.NewEncoder(f).Encode(&entry); err != nil { + t.Fatalf("encode tmp cache: %v", err) + } + _ = f.Close() + if err := os.Rename(tmp, cachePath); err != nil { + t.Fatalf("rename tmp cache: %v", err) + } + + if err := os.Chtimes(target, time.Now(), time.Now()); err != nil { + t.Fatalf("chtimes target: %v", err) + } + + if _, err := loadCacheFromDisk(target); err == nil { + t.Fatalf("expected cache load to fail after reuse window passes") + } +} + +func TestLoadStaleCacheFromDiskAllowsRecentExpiredCache(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + target := filepath.Join(home, "stale-cache-target") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("create target: %v", err) + } + + result := scanResult{TotalSize: 7, TotalFiles: 2} + if err := saveCacheToDisk(target, result); err != nil { + t.Fatalf("saveCacheToDisk: %v", err) + } + + cachePath, err := getCachePath(target) + if err != nil { + t.Fatalf("getCachePath: %v", err) + } + file, err := os.Open(cachePath) + if err != nil { + t.Fatalf("open cache: %v", err) + } + var entry cacheEntry + if err := gob.NewDecoder(file).Decode(&entry); err != nil { + t.Fatalf("decode cache: %v", err) + } + _ = file.Close() + + // Expired for normal cache validation but still inside stale fallback window. + entry.ModTime = time.Now().Add(-48 * time.Hour) + entry.ScanTime = time.Now().Add(-48 * time.Hour) + + tmp := cachePath + ".tmp" + f, err := os.Create(tmp) + if err != nil { + t.Fatalf("create tmp cache: %v", err) + } + if err := gob.NewEncoder(f).Encode(&entry); err != nil { + t.Fatalf("encode tmp cache: %v", err) + } + _ = f.Close() + if err := os.Rename(tmp, cachePath); err != nil { + t.Fatalf("rename tmp cache: %v", err) + } + + if err := os.Chtimes(target, time.Now(), time.Now()); err != nil { + t.Fatalf("chtimes target: %v", err) + } + + if _, err := loadCacheFromDisk(target); err == nil { + t.Fatalf("expected normal cache load to fail") + } + if _, err := loadStaleCacheFromDisk(target); err != nil { + t.Fatalf("expected stale cache load to succeed, got error: %v", err) + } +} + +func TestLoadStaleCacheFromDiskExpiresByStaleTTL(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + target := filepath.Join(home, "stale-cache-expired-target") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("create target: %v", err) + } + + result := scanResult{TotalSize: 9, TotalFiles: 3} + if err := saveCacheToDisk(target, result); err != nil { + t.Fatalf("saveCacheToDisk: %v", err) + } + + cachePath, err := getCachePath(target) + if err != nil { + t.Fatalf("getCachePath: %v", err) + } + file, err := os.Open(cachePath) + if err != nil { + t.Fatalf("open cache: %v", err) + } + var entry cacheEntry + if err := gob.NewDecoder(file).Decode(&entry); err != nil { + t.Fatalf("decode cache: %v", err) + } + _ = file.Close() + + entry.ScanTime = time.Now().Add(-(staleCacheTTL + time.Hour)) + + tmp := cachePath + ".tmp" + f, err := os.Create(tmp) + if err != nil { + t.Fatalf("create tmp cache: %v", err) + } + if err := gob.NewEncoder(f).Encode(&entry); err != nil { + t.Fatalf("encode tmp cache: %v", err) + } + _ = f.Close() + if err := os.Rename(tmp, cachePath); err != nil { + t.Fatalf("rename tmp cache: %v", err) + } + + if _, err := loadStaleCacheFromDisk(target); err == nil { + t.Fatalf("expected stale cache load to fail after stale TTL") + } +} + func TestScanPathPermissionError(t *testing.T) { root := t.TempDir() lockedDir := filepath.Join(root, "locked") diff --git a/cmd/analyze/cache.go b/cmd/analyze/cache.go index 8d4c22b..872a8d5 100644 --- a/cmd/analyze/cache.go +++ b/cmd/analyze/cache.go @@ -182,7 +182,7 @@ func getCachePath(path string) (string, error) { return filepath.Join(cacheDir, filename), nil } -func loadCacheFromDisk(path string) (*cacheEntry, error) { +func loadRawCacheFromDisk(path string) (*cacheEntry, error) { cachePath, err := getCachePath(path) if err != nil { return nil, err @@ -200,23 +200,56 @@ func loadCacheFromDisk(path string) (*cacheEntry, error) { return nil, err } + return &entry, nil +} + +func loadCacheFromDisk(path string) (*cacheEntry, error) { + entry, err := loadRawCacheFromDisk(path) + if err != nil { + return nil, err + } + info, err := os.Stat(path) if err != nil { return nil, err } - if info.ModTime().After(entry.ModTime) { - // Allow grace window. - if cacheModTimeGrace <= 0 || info.ModTime().Sub(entry.ModTime) > cacheModTimeGrace { - return nil, fmt.Errorf("cache expired: directory modified") - } - } - - if time.Since(entry.ScanTime) > 7*24*time.Hour { + scanAge := time.Since(entry.ScanTime) + if scanAge > 7*24*time.Hour { return nil, fmt.Errorf("cache expired: too old") } - return &entry, nil + if info.ModTime().After(entry.ModTime) { + // Allow grace window. + if cacheModTimeGrace <= 0 || info.ModTime().Sub(entry.ModTime) > cacheModTimeGrace { + // Directory mod time is noisy on macOS; reuse recent cache to avoid + // frequent full rescans while still forcing refresh for older entries. + if cacheReuseWindow <= 0 || scanAge > cacheReuseWindow { + return nil, fmt.Errorf("cache expired: directory modified") + } + } + } + + return entry, nil +} + +// loadStaleCacheFromDisk loads cache without strict freshness checks. +// It is used for fast first paint before triggering a background refresh. +func loadStaleCacheFromDisk(path string) (*cacheEntry, error) { + entry, err := loadRawCacheFromDisk(path) + if err != nil { + return nil, err + } + + if _, err := os.Stat(path); err != nil { + return nil, err + } + + if time.Since(entry.ScanTime) > staleCacheTTL { + return nil, fmt.Errorf("stale cache expired") + } + + return entry, nil } func saveCacheToDisk(path string, result scanResult) error { diff --git a/cmd/analyze/constants.go b/cmd/analyze/constants.go index 36301ab..d400035 100644 --- a/cmd/analyze/constants.go +++ b/cmd/analyze/constants.go @@ -16,6 +16,8 @@ const ( maxConcurrentOverview = 8 batchUpdateSize = 100 cacheModTimeGrace = 30 * time.Minute + cacheReuseWindow = 24 * time.Hour + staleCacheTTL = 3 * 24 * time.Hour // Worker pool limits. minWorkers = 16 diff --git a/cmd/analyze/main.go b/cmd/analyze/main.go index e06bc41..4e7db23 100644 --- a/cmd/analyze/main.go +++ b/cmd/analyze/main.go @@ -63,8 +63,10 @@ type historyEntry struct { } type scanResultMsg struct { + path string result scanResult err error + stale bool } type overviewSizeMsg struct { @@ -369,9 +371,19 @@ func (m model) scanCmd(path string) tea.Cmd { Entries: cached.Entries, LargeFiles: cached.LargeFiles, TotalSize: cached.TotalSize, - TotalFiles: 0, // Cache doesn't store file count currently, minor UI limitation + TotalFiles: cached.TotalFiles, } - return scanResultMsg{result: result, err: nil} + return scanResultMsg{path: path, result: result, err: nil} + } + + if stale, err := loadStaleCacheFromDisk(path); err == nil { + result := scanResult{ + Entries: stale.Entries, + LargeFiles: stale.LargeFiles, + TotalSize: stale.TotalSize, + TotalFiles: stale.TotalFiles, + } + return scanResultMsg{path: path, result: result, err: nil, stale: true} } v, err, _ := scanGroup.Do(path, func() (any, error) { @@ -379,7 +391,7 @@ func (m model) scanCmd(path string) tea.Cmd { }) if err != nil { - return scanResultMsg{err: err} + return scanResultMsg{path: path, err: err} } result := v.(scanResult) @@ -390,7 +402,28 @@ func (m model) scanCmd(path string) tea.Cmd { } }(path, result) - return scanResultMsg{result: result, err: nil} + return scanResultMsg{path: path, result: result, err: nil} + } +} + +func (m model) scanFreshCmd(path string) tea.Cmd { + return func() tea.Msg { + v, err, _ := scanGroup.Do(path, func() (any, error) { + return scanPathConcurrent(path, m.filesScanned, m.dirsScanned, m.bytesScanned, m.currentPath) + }) + + if err != nil { + return scanResultMsg{path: path, err: err} + } + + result := v.(scanResult) + go func(p string, r scanResult) { + if err := saveCacheToDisk(p, r); err != nil { + _ = err + } + }(path, result) + + return scanResultMsg{path: path, result: result} } } @@ -442,6 +475,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case scanResultMsg: + if msg.path != "" && msg.path != m.path { + return m, nil + } m.scanning = false if msg.err != nil { m.status = fmt.Sprintf("Scan failed: %v", msg.err) @@ -457,7 +493,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.largeFiles = msg.result.LargeFiles m.totalSize = msg.result.TotalSize m.totalFiles = msg.result.TotalFiles - m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) m.clampEntrySelection() m.clampLargeSelection() m.cache[m.path] = cacheSnapshot(m) @@ -470,6 +505,23 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { _ = storeOverviewSize(path, size) }(m.path, m.totalSize) } + + if msg.stale { + m.status = fmt.Sprintf("Loaded cached data for %s, refreshing...", displayPath(m.path)) + m.scanning = true + if m.totalFiles > 0 { + m.lastTotalFiles = m.totalFiles + } + atomic.StoreInt64(m.filesScanned, 0) + atomic.StoreInt64(m.dirsScanned, 0) + atomic.StoreInt64(m.bytesScanned, 0) + if m.currentPath != nil { + m.currentPath.Store("") + } + return m, tea.Batch(m.scanFreshCmd(m.path), tickCmd()) + } + + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) return m, nil case overviewSizeMsg: delete(m.overviewScanningSet, msg.Path) From 425f23a739d7854e32ba4ce8ac13b36451935982 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 7 Feb 2026 17:43:59 +0800 Subject: [PATCH 66/72] fix(status): improve proxy detection and add parser tests --- cmd/status/metrics.go | 2 +- cmd/status/metrics_network.go | 183 +++++++++++++++++++++++++---- cmd/status/metrics_network_test.go | 60 ++++++++++ 3 files changed, 222 insertions(+), 23 deletions(-) create mode 100644 cmd/status/metrics_network_test.go diff --git a/cmd/status/metrics.go b/cmd/status/metrics.go index d573aa7..508754a 100644 --- a/cmd/status/metrics.go +++ b/cmd/status/metrics.go @@ -160,7 +160,7 @@ const NetworkHistorySize = 120 // Increased history size for wider graph type ProxyStatus struct { Enabled bool - Type string // HTTP, SOCKS, System + Type string // HTTP, HTTPS, SOCKS, PAC, WPAD, TUN Host string } diff --git a/cmd/status/metrics_network.go b/cmd/status/metrics_network.go index 89320b6..d0cd469 100644 --- a/cmd/status/metrics_network.go +++ b/cmd/status/metrics_network.go @@ -2,9 +2,11 @@ package main import ( "context" + "net/url" "os" "runtime" "sort" + "strconv" "strings" "time" @@ -114,23 +116,8 @@ func isNoiseInterface(name string) bool { } func collectProxy() ProxyStatus { - // Check environment variables first. - for _, env := range []string{"https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"} { - if val := os.Getenv(env); val != "" { - proxyType := "HTTP" - if strings.HasPrefix(val, "socks") { - proxyType = "SOCKS" - } - // Extract host. - host := val - if strings.Contains(host, "://") { - host = strings.SplitN(host, "://", 2)[1] - } - if idx := strings.Index(host, "@"); idx >= 0 { - host = host[idx+1:] - } - return ProxyStatus{Enabled: true, Type: proxyType, Host: host} - } + if proxy := collectProxyFromEnv(os.Getenv); proxy.Enabled { + return proxy } // macOS: check system proxy via scutil. @@ -139,14 +126,166 @@ func collectProxy() ProxyStatus { defer cancel() out, err := runCmd(ctx, "scutil", "--proxy") if err == nil { - if strings.Contains(out, "HTTPEnable : 1") || strings.Contains(out, "HTTPSEnable : 1") { - return ProxyStatus{Enabled: true, Type: "System", Host: "System Proxy"} - } - if strings.Contains(out, "SOCKSEnable : 1") { - return ProxyStatus{Enabled: true, Type: "SOCKS", Host: "System Proxy"} + if proxy := collectProxyFromScutilOutput(out); proxy.Enabled { + return proxy } } + + if proxy := collectProxyFromTunInterfaces(); proxy.Enabled { + return proxy + } } return ProxyStatus{Enabled: false} } + +func collectProxyFromEnv(getenv func(string) string) ProxyStatus { + // Include ALL_PROXY for users running proxy tools that only export a single variable. + envKeys := []string{ + "https_proxy", "HTTPS_PROXY", + "http_proxy", "HTTP_PROXY", + "all_proxy", "ALL_PROXY", + } + for _, key := range envKeys { + val := strings.TrimSpace(getenv(key)) + if val == "" { + continue + } + + proxyType := "HTTP" + lower := strings.ToLower(val) + if strings.HasPrefix(lower, "socks") { + proxyType = "SOCKS" + } + + host := parseProxyHost(val) + if host == "" { + host = val + } + return ProxyStatus{Enabled: true, Type: proxyType, Host: host} + } + + return ProxyStatus{Enabled: false} +} + +func collectProxyFromScutilOutput(out string) ProxyStatus { + if out == "" { + return ProxyStatus{Enabled: false} + } + + if scutilProxyEnabled(out, "SOCKSEnable") { + host := joinHostPort(scutilProxyValue(out, "SOCKSProxy"), scutilProxyValue(out, "SOCKSPort")) + if host == "" { + host = "System Proxy" + } + return ProxyStatus{Enabled: true, Type: "SOCKS", Host: host} + } + + if scutilProxyEnabled(out, "HTTPSEnable") { + host := joinHostPort(scutilProxyValue(out, "HTTPSProxy"), scutilProxyValue(out, "HTTPSPort")) + if host == "" { + host = "System Proxy" + } + return ProxyStatus{Enabled: true, Type: "HTTPS", Host: host} + } + + if scutilProxyEnabled(out, "HTTPEnable") { + host := joinHostPort(scutilProxyValue(out, "HTTPProxy"), scutilProxyValue(out, "HTTPPort")) + if host == "" { + host = "System Proxy" + } + return ProxyStatus{Enabled: true, Type: "HTTP", Host: host} + } + + if scutilProxyEnabled(out, "ProxyAutoConfigEnable") { + pacURL := scutilProxyValue(out, "ProxyAutoConfigURLString") + host := parseProxyHost(pacURL) + if host == "" { + host = "PAC" + } + return ProxyStatus{Enabled: true, Type: "PAC", Host: host} + } + + if scutilProxyEnabled(out, "ProxyAutoDiscoveryEnable") { + return ProxyStatus{Enabled: true, Type: "WPAD", Host: "Auto Discovery"} + } + + return ProxyStatus{Enabled: false} +} + +func collectProxyFromTunInterfaces() ProxyStatus { + stats, err := net.IOCounters(true) + if err != nil { + return ProxyStatus{Enabled: false} + } + + var activeTun []string + for _, s := range stats { + lower := strings.ToLower(s.Name) + if strings.HasPrefix(lower, "utun") || strings.HasPrefix(lower, "tun") { + if s.BytesRecv+s.BytesSent > 0 { + activeTun = append(activeTun, s.Name) + } + } + } + if len(activeTun) == 0 { + return ProxyStatus{Enabled: false} + } + sort.Strings(activeTun) + host := activeTun[0] + if len(activeTun) > 1 { + host = activeTun[0] + "+" + } + return ProxyStatus{Enabled: true, Type: "TUN", Host: host} +} + +func scutilProxyEnabled(out, key string) bool { + return scutilProxyValue(out, key) == "1" +} + +func scutilProxyValue(out, key string) string { + prefix := key + " :" + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, prefix) { + return strings.TrimSpace(strings.TrimPrefix(line, prefix)) + } + } + return "" +} + +func parseProxyHost(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + + target := raw + if !strings.Contains(target, "://") { + target = "http://" + target + } + parsed, err := url.Parse(target) + if err != nil { + return "" + } + host := parsed.Host + if host == "" { + return "" + } + return strings.TrimPrefix(host, "@") +} + +func joinHostPort(host, port string) string { + host = strings.TrimSpace(host) + port = strings.TrimSpace(port) + if host == "" { + return "" + } + if port == "" { + return host + } + if _, err := strconv.Atoi(port); err != nil { + return host + } + return host + ":" + port +} diff --git a/cmd/status/metrics_network_test.go b/cmd/status/metrics_network_test.go new file mode 100644 index 0000000..640675a --- /dev/null +++ b/cmd/status/metrics_network_test.go @@ -0,0 +1,60 @@ +package main + +import "testing" + +func TestCollectProxyFromEnvSupportsAllProxy(t *testing.T) { + env := map[string]string{ + "ALL_PROXY": "socks5://127.0.0.1:7890", + } + getenv := func(key string) string { + return env[key] + } + + got := collectProxyFromEnv(getenv) + if !got.Enabled { + t.Fatalf("expected proxy enabled") + } + if got.Type != "SOCKS" { + t.Fatalf("expected SOCKS type, got %s", got.Type) + } + if got.Host != "127.0.0.1:7890" { + t.Fatalf("unexpected host: %s", got.Host) + } +} + +func TestCollectProxyFromScutilOutputPAC(t *testing.T) { + out := ` + { + ProxyAutoConfigEnable : 1 + ProxyAutoConfigURLString : http://127.0.0.1:6152/proxy.pac +}` + got := collectProxyFromScutilOutput(out) + if !got.Enabled { + t.Fatalf("expected proxy enabled") + } + if got.Type != "PAC" { + t.Fatalf("expected PAC type, got %s", got.Type) + } + if got.Host != "127.0.0.1:6152" { + t.Fatalf("unexpected host: %s", got.Host) + } +} + +func TestCollectProxyFromScutilOutputHTTPHostPort(t *testing.T) { + out := ` + { + HTTPEnable : 1 + HTTPProxy : 127.0.0.1 + HTTPPort : 7890 +}` + got := collectProxyFromScutilOutput(out) + if !got.Enabled { + t.Fatalf("expected proxy enabled") + } + if got.Type != "HTTP" { + t.Fatalf("expected HTTP type, got %s", got.Type) + } + if got.Host != "127.0.0.1:7890" { + t.Fatalf("unexpected host: %s", got.Host) + } +} From f0c320e7145599ca42edce5b103f9ee8170197d8 Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 7 Feb 2026 17:44:23 +0800 Subject: [PATCH 67/72] fix(uninstall): warm inline metadata and harden sudo remove --- bin/uninstall.sh | 49 ++++++++++++++++++++++++++++++++++++++++++++ lib/core/file_ops.sh | 2 +- mole | 2 +- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/bin/uninstall.sh b/bin/uninstall.sh index 8b9fcf9..c072ab9 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -36,6 +36,8 @@ readonly MOLE_UNINSTALL_META_CACHE_FILE="$MOLE_UNINSTALL_META_CACHE_DIR/uninstal readonly MOLE_UNINSTALL_META_CACHE_LOCK="${MOLE_UNINSTALL_META_CACHE_FILE}.lock" readonly MOLE_UNINSTALL_META_REFRESH_TTL=604800 # 7 days readonly MOLE_UNINSTALL_SCAN_SPINNER_DELAY_SEC="0.15" +readonly MOLE_UNINSTALL_INLINE_METADATA_LIMIT=4 +readonly MOLE_UNINSTALL_INLINE_MDLS_TIMEOUT_SEC="0.08" uninstall_relative_time_from_epoch() { local value_epoch="${1:-0}" @@ -154,6 +156,34 @@ uninstall_release_metadata_lock() { [[ -d "$lock_dir" ]] && rmdir "$lock_dir" 2> /dev/null || true } +uninstall_collect_inline_metadata() { + local app_path="$1" + local app_mtime="${2:-0}" + local now_epoch="${3:-0}" + + local size_kb + size_kb=$(get_path_size_kb "$app_path") + [[ "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0 + + local last_used_epoch=0 + local metadata_date + metadata_date=$(run_with_timeout "$MOLE_UNINSTALL_INLINE_MDLS_TIMEOUT_SEC" mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "") + if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then + last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0") + fi + + # Fallback to app mtime so first scan does not show "...". + if [[ ! "$last_used_epoch" =~ ^[0-9]+$ || $last_used_epoch -le 0 ]]; then + if [[ "$app_mtime" =~ ^[0-9]+$ && $app_mtime -gt 0 ]]; then + last_used_epoch="$app_mtime" + else + last_used_epoch=0 + fi + fi + + printf "%s|%s|%s\n" "$size_kb" "$last_used_epoch" "$now_epoch" +} + start_uninstall_metadata_refresh() { local refresh_file="$1" [[ ! -s "$refresh_file" ]] && { @@ -567,6 +597,7 @@ scan_applications() { local current_epoch current_epoch=$(get_epoch_seconds) + local inline_metadata_count=0 while IFS='|' read -r app_path display_name bundle_id app_mtime cached_mtime cached_size_kb cached_epoch cached_updated_epoch cached_bundle_id cached_display_name; do [[ -n "$app_path" && -e "$app_path" ]] || continue @@ -610,6 +641,24 @@ scan_applications() { fi if [[ $needs_refresh == true ]]; then + if [[ $inline_metadata_count -lt $MOLE_UNINSTALL_INLINE_METADATA_LIMIT ]]; then + local inline_metadata inline_size_kb inline_epoch inline_updated_epoch + inline_metadata=$(uninstall_collect_inline_metadata "$app_path" "${app_mtime:-0}" "$current_epoch") + IFS='|' read -r inline_size_kb inline_epoch inline_updated_epoch <<< "$inline_metadata" + ((inline_metadata_count++)) + + if [[ "$inline_size_kb" =~ ^[0-9]+$ && $inline_size_kb -gt 0 ]]; then + final_size_kb="$inline_size_kb" + final_size=$(bytes_to_human "$((inline_size_kb * 1024))") + fi + if [[ "$inline_epoch" =~ ^[0-9]+$ && $inline_epoch -gt 0 ]]; then + final_epoch="$inline_epoch" + final_last_used=$(uninstall_relative_time_from_epoch "$final_epoch" "$current_epoch") + fi + if [[ "$inline_updated_epoch" =~ ^[0-9]+$ && $inline_updated_epoch -gt 0 ]]; then + cached_updated_epoch="$inline_updated_epoch" + fi + fi printf "%s|%s|%s|%s\n" "$app_path" "${app_mtime:-0}" "$bundle_id" "$display_name" >> "$refresh_file" fi diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index 8dbbf5e..7415f6a 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -365,7 +365,7 @@ safe_sudo_remove() { fi local output - local ret + local ret=0 output=$(sudo rm -rf "$path" 2>&1) || ret=$? # safe_remove if [[ $ret -eq 0 ]]; then diff --git a/mole b/mole index 096eea0..5f5b353 100755 --- a/mole +++ b/mole @@ -13,7 +13,7 @@ source "$SCRIPT_DIR/lib/core/commands.sh" trap cleanup_temp_files EXIT INT TERM # Version and update helpers -VERSION="1.24.0" +VERSION="1.25.0" MOLE_TAGLINE="Deep clean and optimize your Mac." is_touchid_configured() { From e6829b9a5d15849ab8f8b20b3b7694572bed4d6d Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 7 Feb 2026 17:48:55 +0800 Subject: [PATCH 68/72] fix: improve cache freshness fallback and proxy detection --- bin/uninstall.sh | 49 +++++++ cmd/analyze/analyze_test.go | 215 +++++++++++++++++++++++++++++ cmd/analyze/cache.go | 53 +++++-- cmd/analyze/constants.go | 2 + cmd/analyze/main.go | 62 ++++++++- cmd/status/metrics.go | 2 +- cmd/status/metrics_network.go | 183 +++++++++++++++++++++--- cmd/status/metrics_network_test.go | 60 ++++++++ lib/core/file_ops.sh | 2 +- mole | 2 +- 10 files changed, 590 insertions(+), 40 deletions(-) create mode 100644 cmd/status/metrics_network_test.go diff --git a/bin/uninstall.sh b/bin/uninstall.sh index 8b9fcf9..c072ab9 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -36,6 +36,8 @@ readonly MOLE_UNINSTALL_META_CACHE_FILE="$MOLE_UNINSTALL_META_CACHE_DIR/uninstal readonly MOLE_UNINSTALL_META_CACHE_LOCK="${MOLE_UNINSTALL_META_CACHE_FILE}.lock" readonly MOLE_UNINSTALL_META_REFRESH_TTL=604800 # 7 days readonly MOLE_UNINSTALL_SCAN_SPINNER_DELAY_SEC="0.15" +readonly MOLE_UNINSTALL_INLINE_METADATA_LIMIT=4 +readonly MOLE_UNINSTALL_INLINE_MDLS_TIMEOUT_SEC="0.08" uninstall_relative_time_from_epoch() { local value_epoch="${1:-0}" @@ -154,6 +156,34 @@ uninstall_release_metadata_lock() { [[ -d "$lock_dir" ]] && rmdir "$lock_dir" 2> /dev/null || true } +uninstall_collect_inline_metadata() { + local app_path="$1" + local app_mtime="${2:-0}" + local now_epoch="${3:-0}" + + local size_kb + size_kb=$(get_path_size_kb "$app_path") + [[ "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0 + + local last_used_epoch=0 + local metadata_date + metadata_date=$(run_with_timeout "$MOLE_UNINSTALL_INLINE_MDLS_TIMEOUT_SEC" mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "") + if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then + last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0") + fi + + # Fallback to app mtime so first scan does not show "...". + if [[ ! "$last_used_epoch" =~ ^[0-9]+$ || $last_used_epoch -le 0 ]]; then + if [[ "$app_mtime" =~ ^[0-9]+$ && $app_mtime -gt 0 ]]; then + last_used_epoch="$app_mtime" + else + last_used_epoch=0 + fi + fi + + printf "%s|%s|%s\n" "$size_kb" "$last_used_epoch" "$now_epoch" +} + start_uninstall_metadata_refresh() { local refresh_file="$1" [[ ! -s "$refresh_file" ]] && { @@ -567,6 +597,7 @@ scan_applications() { local current_epoch current_epoch=$(get_epoch_seconds) + local inline_metadata_count=0 while IFS='|' read -r app_path display_name bundle_id app_mtime cached_mtime cached_size_kb cached_epoch cached_updated_epoch cached_bundle_id cached_display_name; do [[ -n "$app_path" && -e "$app_path" ]] || continue @@ -610,6 +641,24 @@ scan_applications() { fi if [[ $needs_refresh == true ]]; then + if [[ $inline_metadata_count -lt $MOLE_UNINSTALL_INLINE_METADATA_LIMIT ]]; then + local inline_metadata inline_size_kb inline_epoch inline_updated_epoch + inline_metadata=$(uninstall_collect_inline_metadata "$app_path" "${app_mtime:-0}" "$current_epoch") + IFS='|' read -r inline_size_kb inline_epoch inline_updated_epoch <<< "$inline_metadata" + ((inline_metadata_count++)) + + if [[ "$inline_size_kb" =~ ^[0-9]+$ && $inline_size_kb -gt 0 ]]; then + final_size_kb="$inline_size_kb" + final_size=$(bytes_to_human "$((inline_size_kb * 1024))") + fi + if [[ "$inline_epoch" =~ ^[0-9]+$ && $inline_epoch -gt 0 ]]; then + final_epoch="$inline_epoch" + final_last_used=$(uninstall_relative_time_from_epoch "$final_epoch" "$current_epoch") + fi + if [[ "$inline_updated_epoch" =~ ^[0-9]+$ && $inline_updated_epoch -gt 0 ]]; then + cached_updated_epoch="$inline_updated_epoch" + fi + fi printf "%s|%s|%s|%s\n" "$app_path" "${app_mtime:-0}" "$bundle_id" "$display_name" >> "$refresh_file" fi diff --git a/cmd/analyze/analyze_test.go b/cmd/analyze/analyze_test.go index 6618c01..25f3636 100644 --- a/cmd/analyze/analyze_test.go +++ b/cmd/analyze/analyze_test.go @@ -415,6 +415,221 @@ func TestLoadCacheExpiresWhenDirectoryChanges(t *testing.T) { } } +func TestLoadCacheReusesRecentEntryAfterDirectoryChanges(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + target := filepath.Join(home, "recent-change-target") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("create target: %v", err) + } + + result := scanResult{TotalSize: 5, TotalFiles: 1} + if err := saveCacheToDisk(target, result); err != nil { + t.Fatalf("saveCacheToDisk: %v", err) + } + + cachePath, err := getCachePath(target) + if err != nil { + t.Fatalf("getCachePath: %v", err) + } + + file, err := os.Open(cachePath) + if err != nil { + t.Fatalf("open cache: %v", err) + } + var entry cacheEntry + if err := gob.NewDecoder(file).Decode(&entry); err != nil { + t.Fatalf("decode cache: %v", err) + } + _ = file.Close() + + // Make cache entry look recently scanned, but older than mod time grace. + entry.ModTime = time.Now().Add(-2 * time.Hour) + entry.ScanTime = time.Now().Add(-1 * time.Hour) + + tmp := cachePath + ".tmp" + f, err := os.Create(tmp) + if err != nil { + t.Fatalf("create tmp cache: %v", err) + } + if err := gob.NewEncoder(f).Encode(&entry); err != nil { + t.Fatalf("encode tmp cache: %v", err) + } + _ = f.Close() + if err := os.Rename(tmp, cachePath); err != nil { + t.Fatalf("rename tmp cache: %v", err) + } + + if err := os.Chtimes(target, time.Now(), time.Now()); err != nil { + t.Fatalf("chtimes target: %v", err) + } + + if _, err := loadCacheFromDisk(target); err != nil { + t.Fatalf("expected recent cache to be reused, got error: %v", err) + } +} + +func TestLoadCacheExpiresWhenModifiedAndReuseWindowPassed(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + target := filepath.Join(home, "reuse-window-target") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("create target: %v", err) + } + + result := scanResult{TotalSize: 5, TotalFiles: 1} + if err := saveCacheToDisk(target, result); err != nil { + t.Fatalf("saveCacheToDisk: %v", err) + } + + cachePath, err := getCachePath(target) + if err != nil { + t.Fatalf("getCachePath: %v", err) + } + + file, err := os.Open(cachePath) + if err != nil { + t.Fatalf("open cache: %v", err) + } + var entry cacheEntry + if err := gob.NewDecoder(file).Decode(&entry); err != nil { + t.Fatalf("decode cache: %v", err) + } + _ = file.Close() + + // Within overall 7-day TTL but beyond reuse window. + entry.ModTime = time.Now().Add(-48 * time.Hour) + entry.ScanTime = time.Now().Add(-(cacheReuseWindow + time.Hour)) + + tmp := cachePath + ".tmp" + f, err := os.Create(tmp) + if err != nil { + t.Fatalf("create tmp cache: %v", err) + } + if err := gob.NewEncoder(f).Encode(&entry); err != nil { + t.Fatalf("encode tmp cache: %v", err) + } + _ = f.Close() + if err := os.Rename(tmp, cachePath); err != nil { + t.Fatalf("rename tmp cache: %v", err) + } + + if err := os.Chtimes(target, time.Now(), time.Now()); err != nil { + t.Fatalf("chtimes target: %v", err) + } + + if _, err := loadCacheFromDisk(target); err == nil { + t.Fatalf("expected cache load to fail after reuse window passes") + } +} + +func TestLoadStaleCacheFromDiskAllowsRecentExpiredCache(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + target := filepath.Join(home, "stale-cache-target") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("create target: %v", err) + } + + result := scanResult{TotalSize: 7, TotalFiles: 2} + if err := saveCacheToDisk(target, result); err != nil { + t.Fatalf("saveCacheToDisk: %v", err) + } + + cachePath, err := getCachePath(target) + if err != nil { + t.Fatalf("getCachePath: %v", err) + } + file, err := os.Open(cachePath) + if err != nil { + t.Fatalf("open cache: %v", err) + } + var entry cacheEntry + if err := gob.NewDecoder(file).Decode(&entry); err != nil { + t.Fatalf("decode cache: %v", err) + } + _ = file.Close() + + // Expired for normal cache validation but still inside stale fallback window. + entry.ModTime = time.Now().Add(-48 * time.Hour) + entry.ScanTime = time.Now().Add(-48 * time.Hour) + + tmp := cachePath + ".tmp" + f, err := os.Create(tmp) + if err != nil { + t.Fatalf("create tmp cache: %v", err) + } + if err := gob.NewEncoder(f).Encode(&entry); err != nil { + t.Fatalf("encode tmp cache: %v", err) + } + _ = f.Close() + if err := os.Rename(tmp, cachePath); err != nil { + t.Fatalf("rename tmp cache: %v", err) + } + + if err := os.Chtimes(target, time.Now(), time.Now()); err != nil { + t.Fatalf("chtimes target: %v", err) + } + + if _, err := loadCacheFromDisk(target); err == nil { + t.Fatalf("expected normal cache load to fail") + } + if _, err := loadStaleCacheFromDisk(target); err != nil { + t.Fatalf("expected stale cache load to succeed, got error: %v", err) + } +} + +func TestLoadStaleCacheFromDiskExpiresByStaleTTL(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + target := filepath.Join(home, "stale-cache-expired-target") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("create target: %v", err) + } + + result := scanResult{TotalSize: 9, TotalFiles: 3} + if err := saveCacheToDisk(target, result); err != nil { + t.Fatalf("saveCacheToDisk: %v", err) + } + + cachePath, err := getCachePath(target) + if err != nil { + t.Fatalf("getCachePath: %v", err) + } + file, err := os.Open(cachePath) + if err != nil { + t.Fatalf("open cache: %v", err) + } + var entry cacheEntry + if err := gob.NewDecoder(file).Decode(&entry); err != nil { + t.Fatalf("decode cache: %v", err) + } + _ = file.Close() + + entry.ScanTime = time.Now().Add(-(staleCacheTTL + time.Hour)) + + tmp := cachePath + ".tmp" + f, err := os.Create(tmp) + if err != nil { + t.Fatalf("create tmp cache: %v", err) + } + if err := gob.NewEncoder(f).Encode(&entry); err != nil { + t.Fatalf("encode tmp cache: %v", err) + } + _ = f.Close() + if err := os.Rename(tmp, cachePath); err != nil { + t.Fatalf("rename tmp cache: %v", err) + } + + if _, err := loadStaleCacheFromDisk(target); err == nil { + t.Fatalf("expected stale cache load to fail after stale TTL") + } +} + func TestScanPathPermissionError(t *testing.T) { root := t.TempDir() lockedDir := filepath.Join(root, "locked") diff --git a/cmd/analyze/cache.go b/cmd/analyze/cache.go index 8d4c22b..872a8d5 100644 --- a/cmd/analyze/cache.go +++ b/cmd/analyze/cache.go @@ -182,7 +182,7 @@ func getCachePath(path string) (string, error) { return filepath.Join(cacheDir, filename), nil } -func loadCacheFromDisk(path string) (*cacheEntry, error) { +func loadRawCacheFromDisk(path string) (*cacheEntry, error) { cachePath, err := getCachePath(path) if err != nil { return nil, err @@ -200,23 +200,56 @@ func loadCacheFromDisk(path string) (*cacheEntry, error) { return nil, err } + return &entry, nil +} + +func loadCacheFromDisk(path string) (*cacheEntry, error) { + entry, err := loadRawCacheFromDisk(path) + if err != nil { + return nil, err + } + info, err := os.Stat(path) if err != nil { return nil, err } - if info.ModTime().After(entry.ModTime) { - // Allow grace window. - if cacheModTimeGrace <= 0 || info.ModTime().Sub(entry.ModTime) > cacheModTimeGrace { - return nil, fmt.Errorf("cache expired: directory modified") - } - } - - if time.Since(entry.ScanTime) > 7*24*time.Hour { + scanAge := time.Since(entry.ScanTime) + if scanAge > 7*24*time.Hour { return nil, fmt.Errorf("cache expired: too old") } - return &entry, nil + if info.ModTime().After(entry.ModTime) { + // Allow grace window. + if cacheModTimeGrace <= 0 || info.ModTime().Sub(entry.ModTime) > cacheModTimeGrace { + // Directory mod time is noisy on macOS; reuse recent cache to avoid + // frequent full rescans while still forcing refresh for older entries. + if cacheReuseWindow <= 0 || scanAge > cacheReuseWindow { + return nil, fmt.Errorf("cache expired: directory modified") + } + } + } + + return entry, nil +} + +// loadStaleCacheFromDisk loads cache without strict freshness checks. +// It is used for fast first paint before triggering a background refresh. +func loadStaleCacheFromDisk(path string) (*cacheEntry, error) { + entry, err := loadRawCacheFromDisk(path) + if err != nil { + return nil, err + } + + if _, err := os.Stat(path); err != nil { + return nil, err + } + + if time.Since(entry.ScanTime) > staleCacheTTL { + return nil, fmt.Errorf("stale cache expired") + } + + return entry, nil } func saveCacheToDisk(path string, result scanResult) error { diff --git a/cmd/analyze/constants.go b/cmd/analyze/constants.go index 36301ab..d400035 100644 --- a/cmd/analyze/constants.go +++ b/cmd/analyze/constants.go @@ -16,6 +16,8 @@ const ( maxConcurrentOverview = 8 batchUpdateSize = 100 cacheModTimeGrace = 30 * time.Minute + cacheReuseWindow = 24 * time.Hour + staleCacheTTL = 3 * 24 * time.Hour // Worker pool limits. minWorkers = 16 diff --git a/cmd/analyze/main.go b/cmd/analyze/main.go index e06bc41..4e7db23 100644 --- a/cmd/analyze/main.go +++ b/cmd/analyze/main.go @@ -63,8 +63,10 @@ type historyEntry struct { } type scanResultMsg struct { + path string result scanResult err error + stale bool } type overviewSizeMsg struct { @@ -369,9 +371,19 @@ func (m model) scanCmd(path string) tea.Cmd { Entries: cached.Entries, LargeFiles: cached.LargeFiles, TotalSize: cached.TotalSize, - TotalFiles: 0, // Cache doesn't store file count currently, minor UI limitation + TotalFiles: cached.TotalFiles, } - return scanResultMsg{result: result, err: nil} + return scanResultMsg{path: path, result: result, err: nil} + } + + if stale, err := loadStaleCacheFromDisk(path); err == nil { + result := scanResult{ + Entries: stale.Entries, + LargeFiles: stale.LargeFiles, + TotalSize: stale.TotalSize, + TotalFiles: stale.TotalFiles, + } + return scanResultMsg{path: path, result: result, err: nil, stale: true} } v, err, _ := scanGroup.Do(path, func() (any, error) { @@ -379,7 +391,7 @@ func (m model) scanCmd(path string) tea.Cmd { }) if err != nil { - return scanResultMsg{err: err} + return scanResultMsg{path: path, err: err} } result := v.(scanResult) @@ -390,7 +402,28 @@ func (m model) scanCmd(path string) tea.Cmd { } }(path, result) - return scanResultMsg{result: result, err: nil} + return scanResultMsg{path: path, result: result, err: nil} + } +} + +func (m model) scanFreshCmd(path string) tea.Cmd { + return func() tea.Msg { + v, err, _ := scanGroup.Do(path, func() (any, error) { + return scanPathConcurrent(path, m.filesScanned, m.dirsScanned, m.bytesScanned, m.currentPath) + }) + + if err != nil { + return scanResultMsg{path: path, err: err} + } + + result := v.(scanResult) + go func(p string, r scanResult) { + if err := saveCacheToDisk(p, r); err != nil { + _ = err + } + }(path, result) + + return scanResultMsg{path: path, result: result} } } @@ -442,6 +475,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case scanResultMsg: + if msg.path != "" && msg.path != m.path { + return m, nil + } m.scanning = false if msg.err != nil { m.status = fmt.Sprintf("Scan failed: %v", msg.err) @@ -457,7 +493,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.largeFiles = msg.result.LargeFiles m.totalSize = msg.result.TotalSize m.totalFiles = msg.result.TotalFiles - m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) m.clampEntrySelection() m.clampLargeSelection() m.cache[m.path] = cacheSnapshot(m) @@ -470,6 +505,23 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { _ = storeOverviewSize(path, size) }(m.path, m.totalSize) } + + if msg.stale { + m.status = fmt.Sprintf("Loaded cached data for %s, refreshing...", displayPath(m.path)) + m.scanning = true + if m.totalFiles > 0 { + m.lastTotalFiles = m.totalFiles + } + atomic.StoreInt64(m.filesScanned, 0) + atomic.StoreInt64(m.dirsScanned, 0) + atomic.StoreInt64(m.bytesScanned, 0) + if m.currentPath != nil { + m.currentPath.Store("") + } + return m, tea.Batch(m.scanFreshCmd(m.path), tickCmd()) + } + + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) return m, nil case overviewSizeMsg: delete(m.overviewScanningSet, msg.Path) diff --git a/cmd/status/metrics.go b/cmd/status/metrics.go index d573aa7..508754a 100644 --- a/cmd/status/metrics.go +++ b/cmd/status/metrics.go @@ -160,7 +160,7 @@ const NetworkHistorySize = 120 // Increased history size for wider graph type ProxyStatus struct { Enabled bool - Type string // HTTP, SOCKS, System + Type string // HTTP, HTTPS, SOCKS, PAC, WPAD, TUN Host string } diff --git a/cmd/status/metrics_network.go b/cmd/status/metrics_network.go index 89320b6..d0cd469 100644 --- a/cmd/status/metrics_network.go +++ b/cmd/status/metrics_network.go @@ -2,9 +2,11 @@ package main import ( "context" + "net/url" "os" "runtime" "sort" + "strconv" "strings" "time" @@ -114,23 +116,8 @@ func isNoiseInterface(name string) bool { } func collectProxy() ProxyStatus { - // Check environment variables first. - for _, env := range []string{"https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"} { - if val := os.Getenv(env); val != "" { - proxyType := "HTTP" - if strings.HasPrefix(val, "socks") { - proxyType = "SOCKS" - } - // Extract host. - host := val - if strings.Contains(host, "://") { - host = strings.SplitN(host, "://", 2)[1] - } - if idx := strings.Index(host, "@"); idx >= 0 { - host = host[idx+1:] - } - return ProxyStatus{Enabled: true, Type: proxyType, Host: host} - } + if proxy := collectProxyFromEnv(os.Getenv); proxy.Enabled { + return proxy } // macOS: check system proxy via scutil. @@ -139,14 +126,166 @@ func collectProxy() ProxyStatus { defer cancel() out, err := runCmd(ctx, "scutil", "--proxy") if err == nil { - if strings.Contains(out, "HTTPEnable : 1") || strings.Contains(out, "HTTPSEnable : 1") { - return ProxyStatus{Enabled: true, Type: "System", Host: "System Proxy"} - } - if strings.Contains(out, "SOCKSEnable : 1") { - return ProxyStatus{Enabled: true, Type: "SOCKS", Host: "System Proxy"} + if proxy := collectProxyFromScutilOutput(out); proxy.Enabled { + return proxy } } + + if proxy := collectProxyFromTunInterfaces(); proxy.Enabled { + return proxy + } } return ProxyStatus{Enabled: false} } + +func collectProxyFromEnv(getenv func(string) string) ProxyStatus { + // Include ALL_PROXY for users running proxy tools that only export a single variable. + envKeys := []string{ + "https_proxy", "HTTPS_PROXY", + "http_proxy", "HTTP_PROXY", + "all_proxy", "ALL_PROXY", + } + for _, key := range envKeys { + val := strings.TrimSpace(getenv(key)) + if val == "" { + continue + } + + proxyType := "HTTP" + lower := strings.ToLower(val) + if strings.HasPrefix(lower, "socks") { + proxyType = "SOCKS" + } + + host := parseProxyHost(val) + if host == "" { + host = val + } + return ProxyStatus{Enabled: true, Type: proxyType, Host: host} + } + + return ProxyStatus{Enabled: false} +} + +func collectProxyFromScutilOutput(out string) ProxyStatus { + if out == "" { + return ProxyStatus{Enabled: false} + } + + if scutilProxyEnabled(out, "SOCKSEnable") { + host := joinHostPort(scutilProxyValue(out, "SOCKSProxy"), scutilProxyValue(out, "SOCKSPort")) + if host == "" { + host = "System Proxy" + } + return ProxyStatus{Enabled: true, Type: "SOCKS", Host: host} + } + + if scutilProxyEnabled(out, "HTTPSEnable") { + host := joinHostPort(scutilProxyValue(out, "HTTPSProxy"), scutilProxyValue(out, "HTTPSPort")) + if host == "" { + host = "System Proxy" + } + return ProxyStatus{Enabled: true, Type: "HTTPS", Host: host} + } + + if scutilProxyEnabled(out, "HTTPEnable") { + host := joinHostPort(scutilProxyValue(out, "HTTPProxy"), scutilProxyValue(out, "HTTPPort")) + if host == "" { + host = "System Proxy" + } + return ProxyStatus{Enabled: true, Type: "HTTP", Host: host} + } + + if scutilProxyEnabled(out, "ProxyAutoConfigEnable") { + pacURL := scutilProxyValue(out, "ProxyAutoConfigURLString") + host := parseProxyHost(pacURL) + if host == "" { + host = "PAC" + } + return ProxyStatus{Enabled: true, Type: "PAC", Host: host} + } + + if scutilProxyEnabled(out, "ProxyAutoDiscoveryEnable") { + return ProxyStatus{Enabled: true, Type: "WPAD", Host: "Auto Discovery"} + } + + return ProxyStatus{Enabled: false} +} + +func collectProxyFromTunInterfaces() ProxyStatus { + stats, err := net.IOCounters(true) + if err != nil { + return ProxyStatus{Enabled: false} + } + + var activeTun []string + for _, s := range stats { + lower := strings.ToLower(s.Name) + if strings.HasPrefix(lower, "utun") || strings.HasPrefix(lower, "tun") { + if s.BytesRecv+s.BytesSent > 0 { + activeTun = append(activeTun, s.Name) + } + } + } + if len(activeTun) == 0 { + return ProxyStatus{Enabled: false} + } + sort.Strings(activeTun) + host := activeTun[0] + if len(activeTun) > 1 { + host = activeTun[0] + "+" + } + return ProxyStatus{Enabled: true, Type: "TUN", Host: host} +} + +func scutilProxyEnabled(out, key string) bool { + return scutilProxyValue(out, key) == "1" +} + +func scutilProxyValue(out, key string) string { + prefix := key + " :" + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, prefix) { + return strings.TrimSpace(strings.TrimPrefix(line, prefix)) + } + } + return "" +} + +func parseProxyHost(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + + target := raw + if !strings.Contains(target, "://") { + target = "http://" + target + } + parsed, err := url.Parse(target) + if err != nil { + return "" + } + host := parsed.Host + if host == "" { + return "" + } + return strings.TrimPrefix(host, "@") +} + +func joinHostPort(host, port string) string { + host = strings.TrimSpace(host) + port = strings.TrimSpace(port) + if host == "" { + return "" + } + if port == "" { + return host + } + if _, err := strconv.Atoi(port); err != nil { + return host + } + return host + ":" + port +} diff --git a/cmd/status/metrics_network_test.go b/cmd/status/metrics_network_test.go new file mode 100644 index 0000000..640675a --- /dev/null +++ b/cmd/status/metrics_network_test.go @@ -0,0 +1,60 @@ +package main + +import "testing" + +func TestCollectProxyFromEnvSupportsAllProxy(t *testing.T) { + env := map[string]string{ + "ALL_PROXY": "socks5://127.0.0.1:7890", + } + getenv := func(key string) string { + return env[key] + } + + got := collectProxyFromEnv(getenv) + if !got.Enabled { + t.Fatalf("expected proxy enabled") + } + if got.Type != "SOCKS" { + t.Fatalf("expected SOCKS type, got %s", got.Type) + } + if got.Host != "127.0.0.1:7890" { + t.Fatalf("unexpected host: %s", got.Host) + } +} + +func TestCollectProxyFromScutilOutputPAC(t *testing.T) { + out := ` + { + ProxyAutoConfigEnable : 1 + ProxyAutoConfigURLString : http://127.0.0.1:6152/proxy.pac +}` + got := collectProxyFromScutilOutput(out) + if !got.Enabled { + t.Fatalf("expected proxy enabled") + } + if got.Type != "PAC" { + t.Fatalf("expected PAC type, got %s", got.Type) + } + if got.Host != "127.0.0.1:6152" { + t.Fatalf("unexpected host: %s", got.Host) + } +} + +func TestCollectProxyFromScutilOutputHTTPHostPort(t *testing.T) { + out := ` + { + HTTPEnable : 1 + HTTPProxy : 127.0.0.1 + HTTPPort : 7890 +}` + got := collectProxyFromScutilOutput(out) + if !got.Enabled { + t.Fatalf("expected proxy enabled") + } + if got.Type != "HTTP" { + t.Fatalf("expected HTTP type, got %s", got.Type) + } + if got.Host != "127.0.0.1:7890" { + t.Fatalf("unexpected host: %s", got.Host) + } +} diff --git a/lib/core/file_ops.sh b/lib/core/file_ops.sh index 8dbbf5e..7415f6a 100644 --- a/lib/core/file_ops.sh +++ b/lib/core/file_ops.sh @@ -365,7 +365,7 @@ safe_sudo_remove() { fi local output - local ret + local ret=0 output=$(sudo rm -rf "$path" 2>&1) || ret=$? # safe_remove if [[ $ret -eq 0 ]]; then diff --git a/mole b/mole index 096eea0..5f5b353 100755 --- a/mole +++ b/mole @@ -13,7 +13,7 @@ source "$SCRIPT_DIR/lib/core/commands.sh" trap cleanup_temp_files EXIT INT TERM # Version and update helpers -VERSION="1.24.0" +VERSION="1.25.0" MOLE_TAGLINE="Deep clean and optimize your Mac." is_touchid_configured() { From f8ff6d54cd974fb56b62e7ebfaf51dd02a1a1270 Mon Sep 17 00:00:00 2001 From: tw93 Date: Mon, 9 Feb 2026 19:34:15 +0800 Subject: [PATCH 69/72] fix(purge): dynamically limit path display width to 70% of terminal, fixing issue #433 --- lib/clean/project.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/clean/project.sh b/lib/clean/project.sh index 4c6606d..fd5f603 100644 --- a/lib/clean/project.sh +++ b/lib/clean/project.sh @@ -1010,9 +1010,10 @@ clean_project_artifacts() { 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-50 chars for project path (increased to accommodate full paths) + # 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 - [[ $available_width -gt 50 ]] && available_width=50 # Truncate project path if needed local truncated_path=$(truncate_by_display_width "$project_path" "$available_width") local current_width=$(get_display_width "$truncated_path") From b2987b0bb94566c2c4cb4d16dd91bde27b7b37b1 Mon Sep 17 00:00:00 2001 From: tw93 Date: Mon, 9 Feb 2026 19:47:50 +0800 Subject: [PATCH 70/72] feat: add VS Code ShipIt cache directories to cleanup list #427 --- lib/core/app_protection.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index bb5fcde..ce03507 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -1103,7 +1103,11 @@ find_app_files() { [[ "$app_name" =~ Godot|godot ]] && [[ -d ~/Library/Application\ Support/Godot ]] && files_to_clean+=("$HOME/Library/Application Support/Godot") # 6. Tools - [[ "$bundle_id" =~ microsoft.*vscode ]] && [[ -d ~/.vscode ]] && files_to_clean+=("$HOME/.vscode") + if [[ "$bundle_id" =~ microsoft.*[vV][sS][cC]ode ]]; then + [[ -d "$HOME/.vscode" ]] && files_to_clean+=("$HOME/.vscode") + [[ -d "$HOME/Library/Caches/com.microsoft.VSCode.ShipIt" ]] && files_to_clean+=("$HOME/Library/Caches/com.microsoft.VSCode.ShipIt") + [[ -d "$HOME/Library/Caches/com.microsoft.VSCodeInsiders.ShipIt" ]] && files_to_clean+=("$HOME/Library/Caches/com.microsoft.VSCodeInsiders.ShipIt") + fi [[ "$app_name" =~ Docker ]] && [[ -d ~/.docker ]] && files_to_clean+=("$HOME/.docker") # 6.1 Maestro Studio From c8b4b085c61160a908cf3d675f4637a283080bc1 Mon Sep 17 00:00:00 2001 From: tw93 Date: Mon, 9 Feb 2026 20:13:59 +0800 Subject: [PATCH 71/72] feat: Add `com.clash.app` to the list of recognized proxy application patterns. --- lib/core/app_protection.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index ce03507..a7edd64 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -687,7 +687,7 @@ should_protect_data() { com.sublimetext.* | com.sublimehq.* | Cursor | Claude | ChatGPT | Ollama) return 0 ;; - com.nssurge.* | com.v2ray.* | com.clash.* | ClashX* | Surge* | Shadowrocket* | Quantumult*) + com.nssurge.* | com.v2ray.* | com.clash.* | com.clash.app | ClashX* | Surge* | Shadowrocket* | Quantumult*) return 0 ;; clash-* | Clash-* | *-clash | *-Clash | clash.* | Clash.* | clash_* | clashverge* | ClashVerge*) From 7813124f33082f4f0427494d4c607c9e38de70f1 Mon Sep 17 00:00:00 2001 From: tw93 Date: Mon, 9 Feb 2026 20:25:10 +0800 Subject: [PATCH 72/72] refactor: Extract `com.clash.app` pattern to avoid ShellCheck redundancy warning --- lib/core/app_protection.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index a7edd64..82e330e 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -687,7 +687,11 @@ should_protect_data() { com.sublimetext.* | com.sublimehq.* | Cursor | Claude | ChatGPT | Ollama) return 0 ;; - com.nssurge.* | com.v2ray.* | com.clash.* | com.clash.app | ClashX* | Surge* | Shadowrocket* | Quantumult*) + # Specific match to avoid ShellCheck redundancy warning with com.clash.* + com.clash.app) + return 0 + ;; + com.nssurge.* | com.v2ray.* | com.clash.* | ClashX* | Surge* | Shadowrocket* | Quantumult*) return 0 ;; clash-* | Clash-* | *-clash | *-Clash | clash.* | Clash.* | clash_* | clashverge* | ClashVerge*)