mirror of
https://github.com/tw93/Mole.git
synced 2026-02-04 15:04:42 +00:00
improve analyze scanning performance, UI responsiveness, symlink navigation
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.
@@ -10,17 +10,17 @@ const (
|
|||||||
defaultViewport = 12 // Default viewport when terminal height is unknown
|
defaultViewport = 12 // Default viewport when terminal height is unknown
|
||||||
overviewCacheTTL = 7 * 24 * time.Hour // 7 days
|
overviewCacheTTL = 7 * 24 * time.Hour // 7 days
|
||||||
overviewCacheFile = "overview_sizes.json"
|
overviewCacheFile = "overview_sizes.json"
|
||||||
duTimeout = 60 * time.Second // Increased for large directories
|
duTimeout = 30 * time.Second // Fail faster to fallback to concurrent scan
|
||||||
mdlsTimeout = 5 * time.Second
|
mdlsTimeout = 5 * time.Second
|
||||||
maxConcurrentOverview = 3 // Scan up to 3 overview dirs concurrently
|
maxConcurrentOverview = 8 // Increased parallel overview scans
|
||||||
batchUpdateSize = 100 // Batch atomic updates every N items
|
batchUpdateSize = 100 // Batch atomic updates every N items
|
||||||
cacheModTimeGrace = 30 * time.Minute // Ignore minor directory mtime bumps
|
cacheModTimeGrace = 30 * time.Minute // Ignore minor directory mtime bumps
|
||||||
|
|
||||||
// Worker pool configuration
|
// Worker pool configuration
|
||||||
minWorkers = 8 // Minimum workers for better I/O throughput
|
minWorkers = 16 // Safe baseline for older machines
|
||||||
maxWorkers = 64 // Maximum workers to avoid excessive goroutines
|
maxWorkers = 64 // Cap at 64 to avoid OS resource contention
|
||||||
cpuMultiplier = 2 // Worker multiplier per CPU core for I/O-bound operations
|
cpuMultiplier = 4 // Balanced CPU usage
|
||||||
maxDirWorkers = 16 // Maximum concurrent subdirectory scans
|
maxDirWorkers = 32 // Limit concurrent subdirectory scans
|
||||||
openCommandTimeout = 10 * time.Second // Timeout for open/reveal commands
|
openCommandTimeout = 10 * time.Second // Timeout for open/reveal commands
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -376,7 +376,7 @@ func (m model) scanCmd(path string) tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func tickCmd() tea.Cmd {
|
func tickCmd() tea.Cmd {
|
||||||
return tea.Tick(time.Millisecond*120, func(t time.Time) tea.Msg {
|
return tea.Tick(time.Millisecond*80, func(t time.Time) tea.Msg {
|
||||||
return tickMsg(t)
|
return tickMsg(t)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -429,7 +429,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.status = fmt.Sprintf("Scan failed: %v", msg.err)
|
m.status = fmt.Sprintf("Scan failed: %v", msg.err)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.entries = msg.result.Entries
|
// Filter out 0-byte items for cleaner view
|
||||||
|
filteredEntries := make([]dirEntry, 0, len(msg.result.Entries))
|
||||||
|
for _, e := range msg.result.Entries {
|
||||||
|
if e.Size > 0 {
|
||||||
|
filteredEntries = append(filteredEntries, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.entries = filteredEntries
|
||||||
m.largeFiles = msg.result.LargeFiles
|
m.largeFiles = msg.result.LargeFiles
|
||||||
m.totalSize = msg.result.TotalSize
|
m.totalSize = msg.result.TotalSize
|
||||||
m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize))
|
m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize))
|
||||||
@@ -639,7 +646,24 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.scanning = false
|
m.scanning = false
|
||||||
return m, nil
|
return m, nil
|
||||||
case "r":
|
case "r":
|
||||||
// Invalidate cache before rescanning to ensure fresh data
|
if m.inOverviewMode() {
|
||||||
|
// In overview mode, clear cache and re-scan known entries
|
||||||
|
m.overviewSizeCache = make(map[string]int64)
|
||||||
|
m.overviewScanningSet = make(map[string]bool)
|
||||||
|
m.hydrateOverviewEntries() // Reset sizes to pending
|
||||||
|
|
||||||
|
// Reset all entries to pending state for visual feedback
|
||||||
|
for i := range m.entries {
|
||||||
|
m.entries[i].Size = -1
|
||||||
|
}
|
||||||
|
m.totalSize = 0
|
||||||
|
|
||||||
|
m.status = "Refreshing..."
|
||||||
|
m.overviewScanning = true
|
||||||
|
return m, tea.Batch(m.scheduleOverviewScans(), tickCmd())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal mode: Invalidate cache before rescanning
|
||||||
invalidateCache(m.path)
|
invalidateCache(m.path)
|
||||||
m.status = "Refreshing..."
|
m.status = "Refreshing..."
|
||||||
m.scanning = true
|
m.scanning = true
|
||||||
|
|||||||
@@ -97,7 +97,17 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
|||||||
// Skip symlinks to avoid following them into unexpected locations
|
// Skip symlinks to avoid following them into unexpected locations
|
||||||
// Use Type() instead of IsDir() to check without following symlinks
|
// Use Type() instead of IsDir() to check without following symlinks
|
||||||
if child.Type()&fs.ModeSymlink != 0 {
|
if child.Type()&fs.ModeSymlink != 0 {
|
||||||
// For symlinks, get their target info but mark them specially
|
// For symlinks, check if they point to a directory
|
||||||
|
targetInfo, err := os.Stat(fullPath)
|
||||||
|
isDir := false
|
||||||
|
if err == nil && targetInfo.IsDir() {
|
||||||
|
isDir = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get symlink size (we don't effectively count the target size towards parent to avoid double counting,
|
||||||
|
// or we just count the link size itself. Existing logic counts 'size' via getActualFileSize on the link info).
|
||||||
|
// Ideally we just want navigation.
|
||||||
|
// Re-fetching info for link itself if needed, but child.Info() does that.
|
||||||
info, err := child.Info()
|
info, err := child.Info()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
@@ -109,7 +119,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
|||||||
Name: child.Name() + " →", // Add arrow to indicate symlink
|
Name: child.Name() + " →", // Add arrow to indicate symlink
|
||||||
Path: fullPath,
|
Path: fullPath,
|
||||||
Size: size,
|
Size: size,
|
||||||
IsDir: false, // Don't allow navigation into symlinks
|
IsDir: isDir, // Allow navigation if target is directory
|
||||||
LastAccess: getLastAccessTimeFromInfo(info),
|
LastAccess: getLastAccessTimeFromInfo(info),
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -287,7 +297,7 @@ func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned *
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
if currentPath != nil {
|
if currentPath != nil && atomic.LoadInt64(filesScanned)%int64(batchUpdateSize) == 0 {
|
||||||
*currentPath = dirPath
|
*currentPath = dirPath
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,8 +506,9 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil
|
|||||||
largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}
|
largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update current path
|
|
||||||
if currentPath != nil {
|
// Update current path occasionally to prevent UI jitter
|
||||||
|
if currentPath != nil && atomic.LoadInt64(filesScanned)%int64(batchUpdateSize) == 0 {
|
||||||
*currentPath = fullPath
|
*currentPath = fullPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -323,18 +323,18 @@ func (m model) View() string {
|
|||||||
if m.inOverviewMode() {
|
if m.inOverviewMode() {
|
||||||
// Show ← Back if there's history (entered from a parent directory)
|
// Show ← Back if there's history (entered from a parent directory)
|
||||||
if len(m.history) > 0 {
|
if len(m.history) > 0 {
|
||||||
fmt.Fprintf(&b, "%s↑↓←→ | Enter | R Refresh | O Open | F File | ← Back | Q Quit%s\n", colorGray, colorReset)
|
fmt.Fprintf(&b, "%s↑↓←→ | Enter | R Refresh | O Open | F File | ← Back | Q Quit%s\n", colorGray, colorReset)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(&b, "%s↑↓→ | Enter | R Refresh | O Open | F File | Q Quit%s\n", colorGray, colorReset)
|
fmt.Fprintf(&b, "%s↑↓→ | Enter | R Refresh | O Open | F File | Q Quit%s\n", colorGray, colorReset)
|
||||||
}
|
}
|
||||||
} else if m.showLargeFiles {
|
} else if m.showLargeFiles {
|
||||||
fmt.Fprintf(&b, "%s↑↓← | R Refresh | O Open | F File | ⌫ Del | ← Back | Q Quit%s\n", colorGray, colorReset)
|
fmt.Fprintf(&b, "%s↑↓← | R Refresh | O Open | F File | ⌫ Del | ← Back | Q Quit%s\n", colorGray, colorReset)
|
||||||
} else {
|
} else {
|
||||||
largeFileCount := len(m.largeFiles)
|
largeFileCount := len(m.largeFiles)
|
||||||
if largeFileCount > 0 {
|
if largeFileCount > 0 {
|
||||||
fmt.Fprintf(&b, "%s↑↓←→ | Enter | R Refresh | O Open | F File | ⌫ Del | T Top(%d) | Q Quit%s\n", colorGray, largeFileCount, colorReset)
|
fmt.Fprintf(&b, "%s↑↓←→ | Enter | R Refresh | O Open | F File | ⌫ Del | T Top(%d) | Q Quit%s\n", colorGray, largeFileCount, colorReset)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(&b, "%s↑↓←→ | Enter | R Refresh | O Open | F File | ⌫ Del | Q Quit%s\n", colorGray, colorReset)
|
fmt.Fprintf(&b, "%s↑↓←→ | Enter | R Refresh | O Open | F File | ⌫ Del | Q Quit%s\n", colorGray, colorReset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if m.deleteConfirm && m.deleteTarget != nil {
|
if m.deleteConfirm && m.deleteTarget != nil {
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ EOF
|
|||||||
items+=('mail_downloads|Mail Downloads|Clear old mail attachments (> 30 days)|true')
|
items+=('mail_downloads|Mail Downloads|Clear old mail attachments (> 30 days)|true')
|
||||||
items+=('swap_cleanup|Swap Refresh|Reset swap files and dynamic pager|true')
|
items+=('swap_cleanup|Swap Refresh|Reset swap files and dynamic pager|true')
|
||||||
items+=('spotlight_cache_cleanup|Spotlight Cache|Clear user-level Spotlight indexes|true')
|
items+=('spotlight_cache_cleanup|Spotlight Cache|Clear user-level Spotlight indexes|true')
|
||||||
items+=('developer_cleanup|Developer Cleanup|Clear Xcode DerivedData & DeviceSupport|false')
|
items+=('developer_cleanup|Developer Cleanup|Clear Xcode DerivedData & DeviceSupport|true')
|
||||||
|
|
||||||
# Output items as JSON
|
# Output items as JSON
|
||||||
local first=true
|
local first=true
|
||||||
|
|||||||
@@ -510,10 +510,10 @@ paginated_multi_select() {
|
|||||||
"${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN}${NC}"
|
"${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN}${NC}"
|
||||||
"${GRAY}Space Select${NC}"
|
"${GRAY}Space Select${NC}"
|
||||||
"${GRAY}Enter${NC}"
|
"${GRAY}Enter${NC}"
|
||||||
"${GRAY}F Refresh${NC}"
|
"${GRAY}R Refresh${NC}"
|
||||||
"${GRAY}${filter_text}${NC}"
|
"${GRAY}${filter_text}${NC}"
|
||||||
"${GRAY}S ${sort_status}${NC}"
|
"${GRAY}S ${sort_status}${NC}"
|
||||||
"${GRAY}R ${reverse_arrow}${NC}"
|
"${GRAY}O ${reverse_arrow}${NC}"
|
||||||
"${GRAY}Q Exit${NC}"
|
"${GRAY}Q Exit${NC}"
|
||||||
)
|
)
|
||||||
_print_wrapped_controls "$sep" "${_segs_all[@]}"
|
_print_wrapped_controls "$sep" "${_segs_all[@]}"
|
||||||
@@ -641,20 +641,60 @@ paginated_multi_select() {
|
|||||||
rebuild_view
|
rebuild_view
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
|
"CHAR:j")
|
||||||
|
if [[ "$filter_mode" != "true" ]]; then
|
||||||
|
# Down navigation
|
||||||
|
if [[ ${#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
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
filter_query+="j"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
"CHAR:k")
|
||||||
|
if [[ "$filter_mode" != "true" ]]; then
|
||||||
|
# Up navigation
|
||||||
|
if [[ ${#view_indices[@]} -gt 0 ]]; then
|
||||||
|
if [[ $cursor_pos -gt 0 ]]; then
|
||||||
|
((cursor_pos--))
|
||||||
|
elif [[ $top_index -gt 0 ]]; then
|
||||||
|
((top_index--))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
filter_query+="k"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
"CHAR:f" | "CHAR:F")
|
"CHAR:f" | "CHAR:F")
|
||||||
|
if [[ "$filter_mode" == "true" ]]; then
|
||||||
|
filter_query+="${key#CHAR:}"
|
||||||
|
fi
|
||||||
|
# F is currently unbound in normal mode to avoid conflict with Refresh (R)
|
||||||
|
;;
|
||||||
|
"CHAR:r" | "CHAR:R")
|
||||||
if [[ "$filter_mode" == "true" ]]; then
|
if [[ "$filter_mode" == "true" ]]; then
|
||||||
filter_query+="${key#CHAR:}"
|
filter_query+="${key#CHAR:}"
|
||||||
else
|
else
|
||||||
# Trigger Refresh signal
|
# Trigger Refresh signal (Unified with Analyze)
|
||||||
cleanup
|
cleanup
|
||||||
return 10
|
return 10
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
"CHAR:r")
|
"CHAR:o" | "CHAR:O")
|
||||||
# lower-case r: behave like reverse when NOT in filter mode
|
|
||||||
if [[ "$filter_mode" == "true" ]]; then
|
if [[ "$filter_mode" == "true" ]]; then
|
||||||
filter_query+="r"
|
filter_query+="${key#CHAR:}"
|
||||||
else
|
elif [[ "$has_metadata" == "true" ]]; then
|
||||||
|
# O toggles reverse order (Unified Sort Order)
|
||||||
if [[ "$sort_reverse" == "true" ]]; then
|
if [[ "$sort_reverse" == "true" ]]; then
|
||||||
sort_reverse="false"
|
sort_reverse="false"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ paginated_multi_select() {
|
|||||||
|
|
||||||
# Clear any remaining lines at bottom
|
# Clear any remaining lines at bottom
|
||||||
printf "${clear_line}\n" >&2
|
printf "${clear_line}\n" >&2
|
||||||
printf "${clear_line}${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space | Enter | Q Exit${NC}\n" >&2
|
printf "${clear_line}${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space | Enter | Q Exit${NC}\n" >&2
|
||||||
|
|
||||||
# Clear one more line to ensure no artifacts
|
# Clear one more line to ensure no artifacts
|
||||||
printf "${clear_line}" >&2
|
printf "${clear_line}" >&2
|
||||||
|
|||||||
Reference in New Issue
Block a user