mirror of
https://github.com/tw93/Mole.git
synced 2026-02-12 16:20:14 +00:00
feat: optimize application scanning performance, improve multi-selection robustness
This commit is contained in:
BIN
bin/analyze-go
BIN
bin/analyze-go
Binary file not shown.
BIN
bin/status-go
BIN
bin/status-go
Binary file not shown.
@@ -67,10 +67,11 @@ format_last_used_summary() {
|
|||||||
|
|
||||||
# Scan applications and collect information
|
# Scan applications and collect information
|
||||||
scan_applications() {
|
scan_applications() {
|
||||||
# Simplified cache: only check timestamp (24h TTL)
|
# Application scan with intelligent caching (24h TTL)
|
||||||
|
# This speeds up repeated scans significantly by caching app metadata
|
||||||
local cache_dir="$HOME/.cache/mole"
|
local cache_dir="$HOME/.cache/mole"
|
||||||
local cache_file="$cache_dir/app_scan_cache"
|
local cache_file="$cache_dir/app_scan_cache"
|
||||||
local cache_ttl=86400
|
local cache_ttl=86400 # 24 hours
|
||||||
local force_rescan="${1:-false}"
|
local force_rescan="${1:-false}"
|
||||||
|
|
||||||
mkdir -p "$cache_dir" 2> /dev/null
|
mkdir -p "$cache_dir" 2> /dev/null
|
||||||
@@ -78,30 +79,34 @@ scan_applications() {
|
|||||||
# Check if cache exists and is fresh
|
# Check if cache exists and is fresh
|
||||||
if [[ $force_rescan == false && -f "$cache_file" ]]; then
|
if [[ $force_rescan == false && -f "$cache_file" ]]; then
|
||||||
local cache_age=$(($(date +%s) - $(get_file_mtime "$cache_file")))
|
local cache_age=$(($(date +%s) - $(get_file_mtime "$cache_file")))
|
||||||
[[ $cache_age -eq $(date +%s) ]] && cache_age=86401
|
[[ $cache_age -eq $(date +%s) ]] && cache_age=86401 # Handle mtime read failure
|
||||||
if [[ $cache_age -lt $cache_ttl ]]; then
|
if [[ $cache_age -lt $cache_ttl ]]; then
|
||||||
|
# Cache hit - show brief feedback and return cached results
|
||||||
if [[ -t 2 ]]; then
|
if [[ -t 2 ]]; then
|
||||||
echo -e "${GREEN}Loading from cache...${NC}" >&2
|
echo -e "${GREEN}Loading from cache...${NC}" >&2
|
||||||
sleep 0.3
|
sleep 0.3 # Brief pause so user sees the message
|
||||||
fi
|
fi
|
||||||
echo "$cache_file"
|
echo "$cache_file"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Cache miss - perform full scan
|
||||||
local inline_loading=false
|
local inline_loading=false
|
||||||
if [[ -t 1 && -t 2 ]]; then
|
if [[ -t 1 && -t 2 ]]; then
|
||||||
inline_loading=true
|
inline_loading=true
|
||||||
printf "\033[2J\033[H" >&2
|
printf "\033[2J\033[H" >&2 # Clear screen for inline loading
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local temp_file
|
local temp_file
|
||||||
temp_file=$(create_temp_file)
|
temp_file=$(create_temp_file)
|
||||||
|
|
||||||
|
# Pre-cache current epoch to avoid repeated date calls
|
||||||
local current_epoch
|
local current_epoch
|
||||||
current_epoch=$(date "+%s")
|
current_epoch=$(date "+%s")
|
||||||
|
|
||||||
# First pass: quickly collect all valid app paths and bundle IDs
|
# First pass: quickly collect all valid app paths and bundle IDs
|
||||||
|
# This pass does NOT call mdls (slow) - only reads plists (fast)
|
||||||
local -a app_data_tuples=()
|
local -a app_data_tuples=()
|
||||||
local -a app_dirs=(
|
local -a app_dirs=(
|
||||||
"/Applications"
|
"/Applications"
|
||||||
@@ -118,35 +123,42 @@ scan_applications() {
|
|||||||
app_name=$(basename "$app_path" .app)
|
app_name=$(basename "$app_path" .app)
|
||||||
|
|
||||||
# Skip nested apps (e.g. inside Wrapper/ or Frameworks/ of another 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
|
local parent_dir
|
||||||
parent_dir=$(dirname "$app_path")
|
parent_dir=$(dirname "$app_path")
|
||||||
if [[ "$parent_dir" == *".app" || "$parent_dir" == *".app/"* ]]; then
|
if [[ "$parent_dir" == *".app" || "$parent_dir" == *".app/"* ]]; then
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Get bundle ID (fast plist read, no mdls call yet)
|
||||||
local bundle_id="unknown"
|
local bundle_id="unknown"
|
||||||
if [[ -f "$app_path/Contents/Info.plist" ]]; then
|
if [[ -f "$app_path/Contents/Info.plist" ]]; then
|
||||||
bundle_id=$(defaults read "$app_path/Contents/Info.plist" CFBundleIdentifier 2> /dev/null || echo "unknown")
|
bundle_id=$(defaults read "$app_path/Contents/Info.plist" CFBundleIdentifier 2> /dev/null || echo "unknown")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Skip system critical apps
|
# Skip system critical apps (input methods, system components, etc.)
|
||||||
if should_protect_from_uninstall "$bundle_id"; then
|
if should_protect_from_uninstall "$bundle_id"; then
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Store tuple: app_path|app_name|bundle_id
|
||||||
|
# Display name and metadata will be resolved in parallel later (second pass)
|
||||||
app_data_tuples+=("${app_path}|${app_name}|${bundle_id}")
|
app_data_tuples+=("${app_path}|${app_name}|${bundle_id}")
|
||||||
done < <(command find "$app_dir" -name "*.app" -maxdepth 3 -print0 2> /dev/null)
|
done < <(command find "$app_dir" -name "*.app" -maxdepth 3 -print0 2> /dev/null)
|
||||||
done
|
done
|
||||||
|
|
||||||
# Second pass: process each app with parallel size calculation
|
# Second pass: process each app with parallel metadata extraction
|
||||||
|
# This pass calls mdls (slow) and calculates sizes, but does so in parallel
|
||||||
local app_count=0
|
local app_count=0
|
||||||
local total_apps=${#app_data_tuples[@]}
|
local total_apps=${#app_data_tuples[@]}
|
||||||
|
# Bound parallelism - for metadata queries, can go higher since it's mostly waiting
|
||||||
local max_parallel
|
local max_parallel
|
||||||
max_parallel=$(get_optimal_parallel_jobs "io")
|
max_parallel=$(get_optimal_parallel_jobs "io")
|
||||||
if [[ $max_parallel -lt 8 ]]; then
|
if [[ $max_parallel -lt 8 ]]; then
|
||||||
max_parallel=8
|
max_parallel=8 # At least 8 for good performance
|
||||||
elif [[ $max_parallel -gt 32 ]]; then
|
elif [[ $max_parallel -gt 32 ]]; then
|
||||||
max_parallel=32
|
max_parallel=32 # Cap at 32 to avoid too many processes
|
||||||
fi
|
fi
|
||||||
local pids=()
|
local pids=()
|
||||||
|
|
||||||
@@ -157,18 +169,25 @@ scan_applications() {
|
|||||||
|
|
||||||
IFS='|' read -r app_path app_name bundle_id <<< "$app_data_tuple"
|
IFS='|' read -r app_path app_name bundle_id <<< "$app_data_tuple"
|
||||||
|
|
||||||
# Get localized display name
|
# Get localized display name (moved from first pass for better performance)
|
||||||
|
# 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
|
||||||
local display_name="$app_name"
|
local display_name="$app_name"
|
||||||
if [[ -f "$app_path/Contents/Info.plist" ]]; then
|
if [[ -f "$app_path/Contents/Info.plist" ]]; then
|
||||||
|
# Try to get localized name from system metadata (best for i18n)
|
||||||
local md_display_name
|
local md_display_name
|
||||||
md_display_name=$(run_with_timeout 0.05 mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "")
|
md_display_name=$(run_with_timeout 0.05 mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "")
|
||||||
|
|
||||||
|
# Get bundle names from plist
|
||||||
local bundle_display_name
|
local bundle_display_name
|
||||||
bundle_display_name=$(plutil -extract CFBundleDisplayName raw "$app_path/Contents/Info.plist" 2> /dev/null)
|
bundle_display_name=$(plutil -extract CFBundleDisplayName raw "$app_path/Contents/Info.plist" 2> /dev/null)
|
||||||
local bundle_name
|
local bundle_name
|
||||||
bundle_name=$(plutil -extract CFBundleName raw "$app_path/Contents/Info.plist" 2> /dev/null)
|
bundle_name=$(plutil -extract CFBundleName raw "$app_path/Contents/Info.plist" 2> /dev/null)
|
||||||
|
|
||||||
# Priority order: MDItemDisplayName > CFBundleDisplayName > CFBundleName
|
# Select best available name
|
||||||
if [[ -n "$md_display_name" && "$md_display_name" != "(null)" && "$md_display_name" != "$app_name" ]]; then
|
if [[ -n "$md_display_name" && "$md_display_name" != "(null)" && "$md_display_name" != "$app_name" ]]; then
|
||||||
display_name="$md_display_name"
|
display_name="$md_display_name"
|
||||||
elif [[ -n "$bundle_display_name" && "$bundle_display_name" != "(null)" ]]; then
|
elif [[ -n "$bundle_display_name" && "$bundle_display_name" != "(null)" ]]; then
|
||||||
@@ -178,19 +197,21 @@ scan_applications() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Parallel size calculation
|
# Calculate app size (in parallel for performance)
|
||||||
local app_size="N/A"
|
local app_size="N/A"
|
||||||
local app_size_kb="0"
|
local app_size_kb="0"
|
||||||
if [[ -d "$app_path" ]]; then
|
if [[ -d "$app_path" ]]; then
|
||||||
|
# Get size in KB, then format for display
|
||||||
app_size_kb=$(get_path_size_kb "$app_path")
|
app_size_kb=$(get_path_size_kb "$app_path")
|
||||||
app_size=$(bytes_to_human "$((app_size_kb * 1024))")
|
app_size=$(bytes_to_human "$((app_size_kb * 1024))")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Get last used date
|
# Get last used date with fallback strategy
|
||||||
local last_used="Never"
|
local last_used="Never"
|
||||||
local last_used_epoch=0
|
local last_used_epoch=0
|
||||||
|
|
||||||
if [[ -d "$app_path" ]]; then
|
if [[ -d "$app_path" ]]; then
|
||||||
|
# Try mdls first with short timeout (0.05s) for accuracy, fallback to mtime for speed
|
||||||
local metadata_date
|
local metadata_date
|
||||||
metadata_date=$(run_with_timeout 0.05 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "")
|
metadata_date=$(run_with_timeout 0.05 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "")
|
||||||
|
|
||||||
@@ -198,6 +219,7 @@ scan_applications() {
|
|||||||
last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0")
|
last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Fallback if mdls failed or returned nothing
|
||||||
if [[ "$last_used_epoch" -eq 0 ]]; then
|
if [[ "$last_used_epoch" -eq 0 ]]; then
|
||||||
last_used_epoch=$(get_file_mtime "$app_path")
|
last_used_epoch=$(get_file_mtime "$app_path")
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -111,8 +111,8 @@ type model struct {
|
|||||||
overviewScanningSet map[string]bool // Track which paths are currently being scanned
|
overviewScanningSet map[string]bool // Track which paths are currently being scanned
|
||||||
width int // Terminal width
|
width int // Terminal width
|
||||||
height int // Terminal height
|
height int // Terminal height
|
||||||
multiSelected map[int]bool // Track multi-selected items by index
|
multiSelected map[string]bool // Track multi-selected items by path (safer than index)
|
||||||
largeMultiSelected map[int]bool // Track multi-selected large files by index
|
largeMultiSelected map[string]bool // Track multi-selected large files by path (safer than index)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) inOverviewMode() bool {
|
func (m model) inOverviewMode() bool {
|
||||||
@@ -179,8 +179,8 @@ func newModel(path string, isOverview bool) model {
|
|||||||
overviewCurrentPath: &overviewCurrentPath,
|
overviewCurrentPath: &overviewCurrentPath,
|
||||||
overviewSizeCache: make(map[string]int64),
|
overviewSizeCache: make(map[string]int64),
|
||||||
overviewScanningSet: make(map[string]bool),
|
overviewScanningSet: make(map[string]bool),
|
||||||
multiSelected: make(map[int]bool),
|
multiSelected: make(map[string]bool),
|
||||||
largeMultiSelected: make(map[int]bool),
|
largeMultiSelected: make(map[string]bool),
|
||||||
}
|
}
|
||||||
|
|
||||||
// In overview mode, create shortcut entries
|
// In overview mode, create shortcut entries
|
||||||
@@ -397,8 +397,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if msg.done {
|
if msg.done {
|
||||||
m.deleting = false
|
m.deleting = false
|
||||||
// Clear multi-selection after delete
|
// Clear multi-selection after delete
|
||||||
m.multiSelected = make(map[int]bool)
|
m.multiSelected = make(map[string]bool)
|
||||||
m.largeMultiSelected = make(map[int]bool)
|
m.largeMultiSelected = make(map[string]bool)
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.status = fmt.Sprintf("Failed to delete: %v", msg.err)
|
m.status = fmt.Sprintf("Failed to delete: %v", msg.err)
|
||||||
} else {
|
} else {
|
||||||
@@ -535,23 +535,20 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.deleteCount = &deleteCount
|
m.deleteCount = &deleteCount
|
||||||
|
|
||||||
// Collect paths to delete (multi-select or single)
|
// Collect paths to delete (multi-select or single)
|
||||||
|
// Using paths instead of indices is safer - avoids deleting wrong files if list changes
|
||||||
var pathsToDelete []string
|
var pathsToDelete []string
|
||||||
if m.showLargeFiles {
|
if m.showLargeFiles {
|
||||||
if len(m.largeMultiSelected) > 0 {
|
if len(m.largeMultiSelected) > 0 {
|
||||||
for idx := range m.largeMultiSelected {
|
for path := range m.largeMultiSelected {
|
||||||
if idx < len(m.largeFiles) {
|
pathsToDelete = append(pathsToDelete, path)
|
||||||
pathsToDelete = append(pathsToDelete, m.largeFiles[idx].Path)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if m.deleteTarget != nil {
|
} else if m.deleteTarget != nil {
|
||||||
pathsToDelete = append(pathsToDelete, m.deleteTarget.Path)
|
pathsToDelete = append(pathsToDelete, m.deleteTarget.Path)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if len(m.multiSelected) > 0 {
|
if len(m.multiSelected) > 0 {
|
||||||
for idx := range m.multiSelected {
|
for path := range m.multiSelected {
|
||||||
if idx < len(m.entries) {
|
pathsToDelete = append(pathsToDelete, path)
|
||||||
pathsToDelete = append(pathsToDelete, m.entries[idx].Path)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if m.deleteTarget != nil {
|
} else if m.deleteTarget != nil {
|
||||||
pathsToDelete = append(pathsToDelete, m.deleteTarget.Path)
|
pathsToDelete = append(pathsToDelete, m.deleteTarget.Path)
|
||||||
@@ -682,8 +679,8 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
case "r":
|
case "r":
|
||||||
// Clear multi-selection on refresh
|
// Clear multi-selection on refresh
|
||||||
m.multiSelected = make(map[int]bool)
|
m.multiSelected = make(map[string]bool)
|
||||||
m.largeMultiSelected = make(map[int]bool)
|
m.largeMultiSelected = make(map[string]bool)
|
||||||
|
|
||||||
if m.inOverviewMode() {
|
if m.inOverviewMode() {
|
||||||
// In overview mode, clear cache and re-scan known entries
|
// In overview mode, clear cache and re-scan known entries
|
||||||
@@ -721,29 +718,32 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
if m.showLargeFiles {
|
if m.showLargeFiles {
|
||||||
m.largeSelected = 0
|
m.largeSelected = 0
|
||||||
m.largeOffset = 0
|
m.largeOffset = 0
|
||||||
m.largeMultiSelected = make(map[int]bool)
|
m.largeMultiSelected = make(map[string]bool)
|
||||||
} else {
|
} else {
|
||||||
m.multiSelected = make(map[int]bool)
|
m.multiSelected = make(map[string]bool)
|
||||||
}
|
}
|
||||||
// Reset status when switching views
|
// Reset status when switching views
|
||||||
m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize))
|
m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize))
|
||||||
}
|
}
|
||||||
case "o":
|
case "o":
|
||||||
// Open selected entries (multi-select aware)
|
// Open selected entries (multi-select aware)
|
||||||
|
// Limit batch operations to prevent system resource exhaustion
|
||||||
|
const maxBatchOpen = 20
|
||||||
if m.showLargeFiles {
|
if m.showLargeFiles {
|
||||||
if len(m.largeFiles) > 0 {
|
if len(m.largeFiles) > 0 {
|
||||||
// Check for multi-selection first
|
// Check for multi-selection first
|
||||||
if len(m.largeMultiSelected) > 0 {
|
if len(m.largeMultiSelected) > 0 {
|
||||||
count := len(m.largeMultiSelected)
|
count := len(m.largeMultiSelected)
|
||||||
for idx := range m.largeMultiSelected {
|
if count > maxBatchOpen {
|
||||||
if idx < len(m.largeFiles) {
|
m.status = fmt.Sprintf("Too many items to open (max %d, selected %d)", maxBatchOpen, count)
|
||||||
path := m.largeFiles[idx].Path
|
return m, nil
|
||||||
go func(p string) {
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
|
for path := range m.largeMultiSelected {
|
||||||
defer cancel()
|
go func(p string) {
|
||||||
_ = exec.CommandContext(ctx, "open", p).Run()
|
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
|
||||||
}(path)
|
defer cancel()
|
||||||
}
|
_ = exec.CommandContext(ctx, "open", p).Run()
|
||||||
|
}(path)
|
||||||
}
|
}
|
||||||
m.status = fmt.Sprintf("Opening %d items...", count)
|
m.status = fmt.Sprintf("Opening %d items...", count)
|
||||||
} else {
|
} else {
|
||||||
@@ -760,15 +760,16 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
// Check for multi-selection first
|
// Check for multi-selection first
|
||||||
if len(m.multiSelected) > 0 {
|
if len(m.multiSelected) > 0 {
|
||||||
count := len(m.multiSelected)
|
count := len(m.multiSelected)
|
||||||
for idx := range m.multiSelected {
|
if count > maxBatchOpen {
|
||||||
if idx < len(m.entries) {
|
m.status = fmt.Sprintf("Too many items to open (max %d, selected %d)", maxBatchOpen, count)
|
||||||
path := m.entries[idx].Path
|
return m, nil
|
||||||
go func(p string) {
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
|
for path := range m.multiSelected {
|
||||||
defer cancel()
|
go func(p string) {
|
||||||
_ = exec.CommandContext(ctx, "open", p).Run()
|
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
|
||||||
}(path)
|
defer cancel()
|
||||||
}
|
_ = exec.CommandContext(ctx, "open", p).Run()
|
||||||
|
}(path)
|
||||||
}
|
}
|
||||||
m.status = fmt.Sprintf("Opening %d items...", count)
|
m.status = fmt.Sprintf("Opening %d items...", count)
|
||||||
} else {
|
} else {
|
||||||
@@ -783,20 +784,23 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
case "f", "F":
|
case "f", "F":
|
||||||
// Reveal selected entries in Finder (multi-select aware)
|
// Reveal selected entries in Finder (multi-select aware)
|
||||||
|
// Limit batch operations to prevent system resource exhaustion
|
||||||
|
const maxBatchReveal = 20
|
||||||
if m.showLargeFiles {
|
if m.showLargeFiles {
|
||||||
if len(m.largeFiles) > 0 {
|
if len(m.largeFiles) > 0 {
|
||||||
// Check for multi-selection first
|
// Check for multi-selection first
|
||||||
if len(m.largeMultiSelected) > 0 {
|
if len(m.largeMultiSelected) > 0 {
|
||||||
count := len(m.largeMultiSelected)
|
count := len(m.largeMultiSelected)
|
||||||
for idx := range m.largeMultiSelected {
|
if count > maxBatchReveal {
|
||||||
if idx < len(m.largeFiles) {
|
m.status = fmt.Sprintf("Too many items to reveal (max %d, selected %d)", maxBatchReveal, count)
|
||||||
path := m.largeFiles[idx].Path
|
return m, nil
|
||||||
go func(p string) {
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
|
for path := range m.largeMultiSelected {
|
||||||
defer cancel()
|
go func(p string) {
|
||||||
_ = exec.CommandContext(ctx, "open", "-R", p).Run()
|
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
|
||||||
}(path)
|
defer cancel()
|
||||||
}
|
_ = exec.CommandContext(ctx, "open", "-R", p).Run()
|
||||||
|
}(path)
|
||||||
}
|
}
|
||||||
m.status = fmt.Sprintf("Showing %d items in Finder...", count)
|
m.status = fmt.Sprintf("Showing %d items in Finder...", count)
|
||||||
} else {
|
} else {
|
||||||
@@ -813,15 +817,16 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
// Check for multi-selection first
|
// Check for multi-selection first
|
||||||
if len(m.multiSelected) > 0 {
|
if len(m.multiSelected) > 0 {
|
||||||
count := len(m.multiSelected)
|
count := len(m.multiSelected)
|
||||||
for idx := range m.multiSelected {
|
if count > maxBatchReveal {
|
||||||
if idx < len(m.entries) {
|
m.status = fmt.Sprintf("Too many items to reveal (max %d, selected %d)", maxBatchReveal, count)
|
||||||
path := m.entries[idx].Path
|
return m, nil
|
||||||
go func(p string) {
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
|
for path := range m.multiSelected {
|
||||||
defer cancel()
|
go func(p string) {
|
||||||
_ = exec.CommandContext(ctx, "open", "-R", p).Run()
|
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
|
||||||
}(path)
|
defer cancel()
|
||||||
}
|
_ = exec.CommandContext(ctx, "open", "-R", p).Run()
|
||||||
|
}(path)
|
||||||
}
|
}
|
||||||
m.status = fmt.Sprintf("Showing %d items in Finder...", count)
|
m.status = fmt.Sprintf("Showing %d items in Finder...", count)
|
||||||
} else {
|
} else {
|
||||||
@@ -836,23 +841,29 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
case " ":
|
case " ":
|
||||||
// Toggle multi-select with spacebar
|
// Toggle multi-select with spacebar
|
||||||
|
// Using paths as keys (instead of indices) is safer and more maintainable
|
||||||
if m.showLargeFiles {
|
if m.showLargeFiles {
|
||||||
if len(m.largeFiles) > 0 {
|
if len(m.largeFiles) > 0 && m.largeSelected < len(m.largeFiles) {
|
||||||
if m.largeMultiSelected == nil {
|
if m.largeMultiSelected == nil {
|
||||||
m.largeMultiSelected = make(map[int]bool)
|
m.largeMultiSelected = make(map[string]bool)
|
||||||
}
|
}
|
||||||
if m.largeMultiSelected[m.largeSelected] {
|
selectedPath := m.largeFiles[m.largeSelected].Path
|
||||||
delete(m.largeMultiSelected, m.largeSelected)
|
if m.largeMultiSelected[selectedPath] {
|
||||||
|
delete(m.largeMultiSelected, selectedPath)
|
||||||
} else {
|
} else {
|
||||||
m.largeMultiSelected[m.largeSelected] = true
|
m.largeMultiSelected[selectedPath] = true
|
||||||
}
|
}
|
||||||
// Update status to show selection count
|
// Update status to show selection count and total size
|
||||||
count := len(m.largeMultiSelected)
|
count := len(m.largeMultiSelected)
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
var totalSize int64
|
var totalSize int64
|
||||||
for idx := range m.largeMultiSelected {
|
// Calculate total size by looking up each selected path
|
||||||
if idx < len(m.largeFiles) {
|
for path := range m.largeMultiSelected {
|
||||||
totalSize += m.largeFiles[idx].Size
|
for _, file := range m.largeFiles {
|
||||||
|
if file.Path == path {
|
||||||
|
totalSize += file.Size
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.status = fmt.Sprintf("%d selected (%s)", count, humanizeBytes(totalSize))
|
m.status = fmt.Sprintf("%d selected (%s)", count, humanizeBytes(totalSize))
|
||||||
@@ -860,22 +871,27 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize))
|
m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if len(m.entries) > 0 && !m.inOverviewMode() {
|
} else if len(m.entries) > 0 && !m.inOverviewMode() && m.selected < len(m.entries) {
|
||||||
if m.multiSelected == nil {
|
if m.multiSelected == nil {
|
||||||
m.multiSelected = make(map[int]bool)
|
m.multiSelected = make(map[string]bool)
|
||||||
}
|
}
|
||||||
if m.multiSelected[m.selected] {
|
selectedPath := m.entries[m.selected].Path
|
||||||
delete(m.multiSelected, m.selected)
|
if m.multiSelected[selectedPath] {
|
||||||
|
delete(m.multiSelected, selectedPath)
|
||||||
} else {
|
} else {
|
||||||
m.multiSelected[m.selected] = true
|
m.multiSelected[selectedPath] = true
|
||||||
}
|
}
|
||||||
// Update status to show selection count
|
// Update status to show selection count and total size
|
||||||
count := len(m.multiSelected)
|
count := len(m.multiSelected)
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
var totalSize int64
|
var totalSize int64
|
||||||
for idx := range m.multiSelected {
|
// Calculate total size by looking up each selected path
|
||||||
if idx < len(m.entries) {
|
for path := range m.multiSelected {
|
||||||
totalSize += m.entries[idx].Size
|
for _, entry := range m.entries {
|
||||||
|
if entry.Path == path {
|
||||||
|
totalSize += entry.Size
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.status = fmt.Sprintf("%d selected (%s)", count, humanizeBytes(totalSize))
|
m.status = fmt.Sprintf("%d selected (%s)", count, humanizeBytes(totalSize))
|
||||||
@@ -891,19 +907,22 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
if len(m.largeMultiSelected) > 0 {
|
if len(m.largeMultiSelected) > 0 {
|
||||||
m.deleteConfirm = true
|
m.deleteConfirm = true
|
||||||
// Set deleteTarget to first selected for display purposes
|
// Set deleteTarget to first selected for display purposes
|
||||||
for idx := range m.largeMultiSelected {
|
for path := range m.largeMultiSelected {
|
||||||
if idx < len(m.largeFiles) {
|
// Find the file entry by path
|
||||||
selected := m.largeFiles[idx]
|
for _, file := range m.largeFiles {
|
||||||
m.deleteTarget = &dirEntry{
|
if file.Path == path {
|
||||||
Name: selected.Name,
|
m.deleteTarget = &dirEntry{
|
||||||
Path: selected.Path,
|
Name: file.Name,
|
||||||
Size: selected.Size,
|
Path: file.Path,
|
||||||
IsDir: false,
|
Size: file.Size,
|
||||||
|
IsDir: false,
|
||||||
|
}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
break // Only need first one for display
|
||||||
}
|
}
|
||||||
} else {
|
} else if m.largeSelected < len(m.largeFiles) {
|
||||||
selected := m.largeFiles[m.largeSelected]
|
selected := m.largeFiles[m.largeSelected]
|
||||||
m.deleteConfirm = true
|
m.deleteConfirm = true
|
||||||
m.deleteTarget = &dirEntry{
|
m.deleteTarget = &dirEntry{
|
||||||
@@ -919,13 +938,17 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
if len(m.multiSelected) > 0 {
|
if len(m.multiSelected) > 0 {
|
||||||
m.deleteConfirm = true
|
m.deleteConfirm = true
|
||||||
// Set deleteTarget to first selected for display purposes
|
// Set deleteTarget to first selected for display purposes
|
||||||
for idx := range m.multiSelected {
|
for path := range m.multiSelected {
|
||||||
if idx < len(m.entries) {
|
// Find the entry by path
|
||||||
m.deleteTarget = &m.entries[idx]
|
for i := range m.entries {
|
||||||
break
|
if m.entries[i].Path == path {
|
||||||
|
m.deleteTarget = &m.entries[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
break // Only need first one for display
|
||||||
}
|
}
|
||||||
} else {
|
} else if m.selected < len(m.entries) {
|
||||||
selected := m.entries[m.selected]
|
selected := m.entries[m.selected]
|
||||||
m.deleteConfirm = true
|
m.deleteConfirm = true
|
||||||
m.deleteTarget = &selected
|
m.deleteTarget = &selected
|
||||||
@@ -972,8 +995,8 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) {
|
|||||||
m.scanning = true
|
m.scanning = true
|
||||||
m.isOverview = false
|
m.isOverview = false
|
||||||
// Clear multi-selection when entering new directory
|
// Clear multi-selection when entering new directory
|
||||||
m.multiSelected = make(map[int]bool)
|
m.multiSelected = make(map[string]bool)
|
||||||
m.largeMultiSelected = make(map[int]bool)
|
m.largeMultiSelected = make(map[string]bool)
|
||||||
|
|
||||||
// Reset scan counters for new scan
|
// Reset scan counters for new scan
|
||||||
atomic.StoreInt64(m.filesScanned, 0)
|
atomic.StoreInt64(m.filesScanned, 0)
|
||||||
|
|||||||
@@ -130,8 +130,8 @@ func (m model) View() string {
|
|||||||
sizeColor := colorGray
|
sizeColor := colorGray
|
||||||
numColor := ""
|
numColor := ""
|
||||||
|
|
||||||
// Check if this item is multi-selected
|
// Check if this item is multi-selected (by path, not index)
|
||||||
isMultiSelected := m.largeMultiSelected != nil && m.largeMultiSelected[idx]
|
isMultiSelected := m.largeMultiSelected != nil && m.largeMultiSelected[file.Path]
|
||||||
selectIcon := "○"
|
selectIcon := "○"
|
||||||
if isMultiSelected {
|
if isMultiSelected {
|
||||||
selectIcon = fmt.Sprintf("%s●%s", colorGreen, colorReset)
|
selectIcon = fmt.Sprintf("%s●%s", colorGreen, colorReset)
|
||||||
@@ -289,8 +289,8 @@ func (m model) View() string {
|
|||||||
sizeColor = colorGray
|
sizeColor = colorGray
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this item is multi-selected
|
// Check if this item is multi-selected (by path, not index)
|
||||||
isMultiSelected := m.multiSelected != nil && m.multiSelected[idx]
|
isMultiSelected := m.multiSelected != nil && m.multiSelected[entry.Path]
|
||||||
selectIcon := "○"
|
selectIcon := "○"
|
||||||
nameColor := ""
|
nameColor := ""
|
||||||
if isMultiSelected {
|
if isMultiSelected {
|
||||||
@@ -386,16 +386,24 @@ func (m model) View() string {
|
|||||||
var totalDeleteSize int64
|
var totalDeleteSize int64
|
||||||
if m.showLargeFiles && len(m.largeMultiSelected) > 0 {
|
if m.showLargeFiles && len(m.largeMultiSelected) > 0 {
|
||||||
deleteCount = len(m.largeMultiSelected)
|
deleteCount = len(m.largeMultiSelected)
|
||||||
for idx := range m.largeMultiSelected {
|
// Calculate total size by looking up each selected path
|
||||||
if idx < len(m.largeFiles) {
|
for path := range m.largeMultiSelected {
|
||||||
totalDeleteSize += m.largeFiles[idx].Size
|
for _, file := range m.largeFiles {
|
||||||
|
if file.Path == path {
|
||||||
|
totalDeleteSize += file.Size
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if !m.showLargeFiles && len(m.multiSelected) > 0 {
|
} else if !m.showLargeFiles && len(m.multiSelected) > 0 {
|
||||||
deleteCount = len(m.multiSelected)
|
deleteCount = len(m.multiSelected)
|
||||||
for idx := range m.multiSelected {
|
// Calculate total size by looking up each selected path
|
||||||
if idx < len(m.entries) {
|
for path := range m.multiSelected {
|
||||||
totalDeleteSize += m.entries[idx].Size
|
for _, entry := range m.entries {
|
||||||
|
if entry.Path == path {
|
||||||
|
totalDeleteSize += entry.Size
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -486,28 +486,6 @@ should_protect_path() {
|
|||||||
local path="$1"
|
local path="$1"
|
||||||
[[ -z "$path" ]] && return 1
|
[[ -z "$path" ]] && return 1
|
||||||
|
|
||||||
# 0. Check custom protected paths first
|
|
||||||
local custom_config="$HOME/.config/mole/protected_paths"
|
|
||||||
if [[ -f "$custom_config" ]]; then
|
|
||||||
while IFS= read -r protected_path; do
|
|
||||||
# Trim whitespace
|
|
||||||
protected_path="${protected_path#"${protected_path%%[![:space:]]*}"}"
|
|
||||||
protected_path="${protected_path%"${protected_path##*[![:space:]]}"}"
|
|
||||||
|
|
||||||
# Skip empty lines and comments
|
|
||||||
[[ -z "$protected_path" || "$protected_path" =~ ^# ]] && continue
|
|
||||||
|
|
||||||
# Expand ~ to $HOME in protected path
|
|
||||||
protected_path="${protected_path/#\~/$HOME}"
|
|
||||||
|
|
||||||
# Check if path starts with protected path (prefix match)
|
|
||||||
# This protects both the directory and everything under it
|
|
||||||
if [[ "$path" == "$protected_path" || "$path" == "$protected_path"/* ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
done < "$custom_config"
|
|
||||||
fi
|
|
||||||
|
|
||||||
local path_lower
|
local path_lower
|
||||||
path_lower=$(echo "$path" | tr '[:upper:]' '[:lower:]')
|
path_lower=$(echo "$path" | tr '[:upper:]' '[:lower:]')
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ save_whitelist_patterns() {
|
|||||||
header_text="# Mole Optimization Whitelist - These checks will be skipped during optimization"
|
header_text="# Mole Optimization Whitelist - These checks will be skipped during optimization"
|
||||||
else
|
else
|
||||||
config_file="$WHITELIST_CONFIG_CLEAN"
|
config_file="$WHITELIST_CONFIG_CLEAN"
|
||||||
header_text="# Mole Whitelist - Protected paths won't be deleted\n# Default protections: Playwright browsers, HuggingFace models, Maven repo, Ollama models, Surge Mac, R renv, Finder metadata\n#\n# Add one pattern per line to keep items safe.\n#\n# You can also add custom paths to protect (e.g., ~/important-project, /opt/myapp):\n# ~/my-project\n# ~/.config/important-app"
|
header_text="# Mole Whitelist - Protected paths won't be deleted\n# Default protections: Playwright browsers, HuggingFace models, Maven repo, Ollama models, Surge Mac, R renv, Finder metadata\n# Add one pattern per line to keep items safe."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p "$(dirname "$config_file")"
|
mkdir -p "$(dirname "$config_file")"
|
||||||
|
|||||||
Reference in New Issue
Block a user