diff --git a/.gitignore b/.gitignore index 65779fd..1d5d63e 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ copilot-instructions.md cmd/analyze/analyze cmd/status/status /status +bin/analyze-go +bin/status-go +mole-analyze diff --git a/bin/analyze-go b/bin/analyze-go deleted file mode 100755 index 1abef02..0000000 Binary files a/bin/analyze-go and /dev/null differ diff --git a/bin/clean.sh b/bin/clean.sh index e098fa2..6711a32 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -258,7 +258,7 @@ safe_clean() { for path in "${existing_paths[@]}"; do ( local size - size=$(du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0") + size=$(get_path_size_kb "$path") local count count=$(find "$path" -type f 2> /dev/null | wc -l | tr -d ' ') # Use index + PID for unique filename @@ -317,7 +317,7 @@ safe_clean() { for path in "${existing_paths[@]}"; do local size_bytes - size_bytes=$(du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0") + size_bytes=$(get_path_size_kb "$path") local count count=$(find "$path" -type f 2> /dev/null | wc -l | tr -d ' ') @@ -718,7 +718,7 @@ perform_cleanup() { if is_orphaned "$bundle_id" "$match"; then # Use timeout to prevent du from hanging on large/problematic directories local size_kb - size_kb=$(run_with_timeout 2 du -sk "$match" 2> /dev/null | awk '{print $1}' || echo "0") + size_kb=$(run_with_timeout 2 get_path_size_kb "$match") if [[ -z "$size_kb" || "$size_kb" == "0" ]]; then continue fi diff --git a/bin/optimize.sh b/bin/optimize.sh index c3ff75e..2fb9f30 100755 --- a/bin/optimize.sh +++ b/bin/optimize.sh @@ -179,7 +179,7 @@ cleanup_path() { fi local size_kb - size_kb=$(du -sk "$expanded_path" 2> /dev/null | awk '{print $1}' || echo "0") + size_kb=$(get_path_size_kb "$expanded_path") local size_display="" if [[ "$size_kb" =~ ^[0-9]+$ && "$size_kb" -gt 0 ]]; then size_display=$(bytes_to_human "$((size_kb * 1024))") diff --git a/bin/status-go b/bin/status-go deleted file mode 100755 index 0d851ec..0000000 Binary files a/bin/status-go and /dev/null differ diff --git a/bin/uninstall.sh b/bin/uninstall.sh index 0f79c72..a7a252d 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -217,8 +217,8 @@ scan_applications() { local app_size="N/A" local app_size_kb="0" if [[ -d "$app_path" ]]; then - # Get size in KB, then format for display (single du call) - app_size_kb=$(du -sk "$app_path" 2> /dev/null | awk '{print $1}' || echo "0") + # 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 diff --git a/cmd/analyze/main.go b/cmd/analyze/main.go index abdf016..95755fe 100644 --- a/cmd/analyze/main.go +++ b/cmd/analyze/main.go @@ -789,344 +789,6 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) { return m, nil } -func (m model) View() string { - var b strings.Builder - fmt.Fprintln(&b) - - if m.inOverviewMode() { - fmt.Fprintf(&b, "%sAnalyze Disk%s\n", colorPurpleBold, colorReset) - if m.overviewScanning { - // Check if we're in initial scan (all entries are pending) - allPending := true - for _, entry := range m.entries { - if entry.Size >= 0 { - allPending = false - break - } - } - - if allPending { - // Show prominent loading screen for initial scan - fmt.Fprintf(&b, "%s%s%s%s Analyzing disk usage, please wait...%s\n", - colorCyan, colorBold, - spinnerFrames[m.spinner], - colorReset, colorReset) - return b.String() - } else { - // Progressive scanning - show subtle indicator - fmt.Fprintf(&b, "%sSelect a location to explore:%s ", colorGray, colorReset) - fmt.Fprintf(&b, "%s%s%s%s Scanning...\n\n", colorCyan, colorBold, spinnerFrames[m.spinner], colorReset) - } - } else { - // Check if there are still pending items - hasPending := false - for _, entry := range m.entries { - if entry.Size < 0 { - hasPending = true - break - } - } - if hasPending { - fmt.Fprintf(&b, "%sSelect a location to explore:%s ", colorGray, colorReset) - fmt.Fprintf(&b, "%s%s%s%s Scanning...\n\n", colorCyan, colorBold, spinnerFrames[m.spinner], colorReset) - } else { - fmt.Fprintf(&b, "%sSelect a location to explore:%s\n\n", colorGray, colorReset) - } - } - } else { - fmt.Fprintf(&b, "%sAnalyze Disk%s %s%s%s", colorPurpleBold, colorReset, colorGray, displayPath(m.path), colorReset) - if !m.scanning { - fmt.Fprintf(&b, " | Total: %s", humanizeBytes(m.totalSize)) - } - fmt.Fprintf(&b, "\n\n") - } - - if m.deleting { - // Show delete progress - count := int64(0) - if m.deleteCount != nil { - count = atomic.LoadInt64(m.deleteCount) - } - - fmt.Fprintf(&b, "%s%s%s%s Deleting: %s%s items%s removed, please wait...\n", - colorCyan, colorBold, - spinnerFrames[m.spinner], - colorReset, - colorYellow, formatNumber(count), colorReset) - - return b.String() - } - - if m.scanning { - filesScanned, dirsScanned, bytesScanned := m.getScanProgress() - - fmt.Fprintf(&b, "%s%s%s%s Scanning: %s%s files%s, %s%s dirs%s, %s%s%s\n", - colorCyan, colorBold, - spinnerFrames[m.spinner], - colorReset, - colorYellow, formatNumber(filesScanned), colorReset, - colorYellow, formatNumber(dirsScanned), colorReset, - colorGreen, humanizeBytes(bytesScanned), colorReset) - - if m.currentPath != nil { - currentPath := *m.currentPath - if currentPath != "" { - shortPath := displayPath(currentPath) - shortPath = truncateMiddle(shortPath, 50) - fmt.Fprintf(&b, "%s%s%s\n", colorGray, shortPath, colorReset) - } - } - - return b.String() - } - - if m.showLargeFiles { - if len(m.largeFiles) == 0 { - fmt.Fprintln(&b, " No large files found (>=100MB)") - } else { - viewport := calculateViewport(m.height, true) - start := m.largeOffset - if start < 0 { - start = 0 - } - end := start + viewport - if end > len(m.largeFiles) { - end = len(m.largeFiles) - } - maxLargeSize := int64(1) - for _, file := range m.largeFiles { - if file.Size > maxLargeSize { - maxLargeSize = file.Size - } - } - for idx := start; idx < end; idx++ { - file := m.largeFiles[idx] - shortPath := displayPath(file.Path) - shortPath = truncateMiddle(shortPath, 35) - paddedPath := padName(shortPath, 35) - entryPrefix := " " - nameColor := "" - sizeColor := colorGray - numColor := "" - if idx == m.largeSelected { - entryPrefix = fmt.Sprintf(" %s%sโ–ถ%s ", colorCyan, colorBold, colorReset) - nameColor = colorCyan - sizeColor = colorCyan - numColor = colorCyan - } - size := humanizeBytes(file.Size) - bar := coloredProgressBar(file.Size, maxLargeSize, 0) - fmt.Fprintf(&b, "%s%s%2d.%s %s | ๐Ÿ“„ %s%s%s %s%10s%s\n", - entryPrefix, numColor, idx+1, colorReset, bar, nameColor, paddedPath, colorReset, sizeColor, size, colorReset) - } - } - } else { - if len(m.entries) == 0 { - fmt.Fprintln(&b, " Empty directory") - } else { - if m.inOverviewMode() { - maxSize := int64(1) - for _, entry := range m.entries { - if entry.Size > maxSize { - maxSize = entry.Size - } - } - totalSize := m.totalSize - for idx, entry := range m.entries { - icon := "๐Ÿ“" - sizeVal := entry.Size - barValue := sizeVal - if barValue < 0 { - barValue = 0 - } - var percent float64 - if totalSize > 0 && sizeVal >= 0 { - percent = float64(sizeVal) / float64(totalSize) * 100 - } else { - percent = 0 - } - percentStr := fmt.Sprintf("%5.1f%%", percent) - if totalSize == 0 || sizeVal < 0 { - percentStr = " -- " - } - bar := coloredProgressBar(barValue, maxSize, percent) - sizeText := "pending.." - if sizeVal >= 0 { - sizeText = humanizeBytes(sizeVal) - } - sizeColor := colorGray - if sizeVal >= 0 && totalSize > 0 { - switch { - case percent >= 50: - sizeColor = colorRed - case percent >= 20: - sizeColor = colorYellow - case percent >= 5: - sizeColor = colorBlue - default: - sizeColor = colorGray - } - } - entryPrefix := " " - name := trimName(entry.Name) - paddedName := padName(name, 28) - nameSegment := fmt.Sprintf("%s %s", icon, paddedName) - numColor := "" - percentColor := "" - if idx == m.selected { - entryPrefix = fmt.Sprintf(" %s%sโ–ถ%s ", colorCyan, colorBold, colorReset) - nameSegment = fmt.Sprintf("%s%s %s%s", colorCyan, icon, paddedName, colorReset) - numColor = colorCyan - percentColor = colorCyan - sizeColor = colorCyan - } - displayIndex := idx + 1 - - // Priority: cleanable > unused time - var hintLabel string - if entry.IsDir && isCleanableDir(entry.Path) { - hintLabel = fmt.Sprintf("%s๐Ÿงน%s", colorYellow, colorReset) - } else { - // For overview mode, get access time on-demand if not set - lastAccess := entry.LastAccess - if lastAccess.IsZero() && entry.Path != "" { - lastAccess = getLastAccessTime(entry.Path) - } - if unusedTime := formatUnusedTime(lastAccess); unusedTime != "" { - hintLabel = fmt.Sprintf("%s%s%s", colorGray, unusedTime, colorReset) - } - } - - if hintLabel == "" { - fmt.Fprintf(&b, "%s%s%2d.%s %s %s%s%s | %s %s%10s%s\n", - entryPrefix, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset, - nameSegment, sizeColor, sizeText, colorReset) - } else { - fmt.Fprintf(&b, "%s%s%2d.%s %s %s%s%s | %s %s%10s%s %s\n", - entryPrefix, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset, - nameSegment, sizeColor, sizeText, colorReset, hintLabel) - } - } - } else { - // Normal mode with sizes and progress bars - maxSize := int64(1) - for _, entry := range m.entries { - if entry.Size > maxSize { - maxSize = entry.Size - } - } - - viewport := calculateViewport(m.height, false) - start := m.offset - if start < 0 { - start = 0 - } - end := start + viewport - if end > len(m.entries) { - end = len(m.entries) - } - - for idx := start; idx < end; idx++ { - entry := m.entries[idx] - icon := "๐Ÿ“„" - if entry.IsDir { - icon = "๐Ÿ“" - } - size := humanizeBytes(entry.Size) - name := trimName(entry.Name) - paddedName := padName(name, 28) - - // Calculate percentage - percent := float64(entry.Size) / float64(m.totalSize) * 100 - percentStr := fmt.Sprintf("%5.1f%%", percent) - - // Get colored progress bar - bar := coloredProgressBar(entry.Size, maxSize, percent) - - // Color the size based on magnitude - var sizeColor string - if percent >= 50 { - sizeColor = colorRed - } else if percent >= 20 { - sizeColor = colorYellow - } else if percent >= 5 { - sizeColor = colorBlue - } else { - sizeColor = colorGray - } - - // Keep chart columns aligned even when arrow is shown - entryPrefix := " " - nameSegment := fmt.Sprintf("%s %s", icon, paddedName) - numColor := "" - percentColor := "" - if idx == m.selected { - entryPrefix = fmt.Sprintf(" %s%sโ–ถ%s ", colorCyan, colorBold, colorReset) - nameSegment = fmt.Sprintf("%s%s %s%s", colorCyan, icon, paddedName, colorReset) - numColor = colorCyan - percentColor = colorCyan - sizeColor = colorCyan - } - - displayIndex := idx + 1 - - // Priority: cleanable > unused time - var hintLabel string - if entry.IsDir && isCleanableDir(entry.Path) { - hintLabel = fmt.Sprintf("%s๐Ÿงน%s", colorYellow, colorReset) - } else { - // Get access time on-demand if not set - lastAccess := entry.LastAccess - if lastAccess.IsZero() && entry.Path != "" { - lastAccess = getLastAccessTime(entry.Path) - } - if unusedTime := formatUnusedTime(lastAccess); unusedTime != "" { - hintLabel = fmt.Sprintf("%s%s%s", colorGray, unusedTime, colorReset) - } - } - - if hintLabel == "" { - fmt.Fprintf(&b, "%s%s%2d.%s %s %s%s%s | %s %s%10s%s\n", - entryPrefix, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset, - nameSegment, sizeColor, size, colorReset) - } else { - fmt.Fprintf(&b, "%s%s%2d.%s %s %s%s%s | %s %s%10s%s %s\n", - entryPrefix, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset, - nameSegment, sizeColor, size, colorReset, hintLabel) - } - } - } - } - } - - fmt.Fprintln(&b) - if m.inOverviewMode() { - // Show โ† Back if there's history (entered from a parent directory) - if len(m.history) > 0 { - fmt.Fprintf(&b, "%sโ†‘โ†“โ†โ†’ | Enter | R Refresh | O Open | F Show | โ† Back | Q Quit%s\n", colorGray, colorReset) - } else { - fmt.Fprintf(&b, "%sโ†‘โ†“โ†’ | Enter | R Refresh | O Open | F Show | Q Quit%s\n", colorGray, colorReset) - } - } else if m.showLargeFiles { - fmt.Fprintf(&b, "%sโ†‘โ†“โ† | R Refresh | O Open | F Show | โŒซ Delete | โ† Back | Q Quit%s\n", colorGray, colorReset) - } else { - largeFileCount := len(m.largeFiles) - if largeFileCount > 0 { - fmt.Fprintf(&b, "%sโ†‘โ†“โ†โ†’ | Enter | R Refresh | O Open | F Show | โŒซ Delete | T Top(%d) | Q Quit%s\n", colorGray, largeFileCount, colorReset) - } else { - fmt.Fprintf(&b, "%sโ†‘โ†“โ†โ†’ | Enter | R Refresh | O Open | F Show | โŒซ Delete | Q Quit%s\n", colorGray, colorReset) - } - } - if m.deleteConfirm && m.deleteTarget != nil { - fmt.Fprintln(&b) - fmt.Fprintf(&b, "%sDelete:%s %s (%s) %sPress โŒซ again | ESC cancel%s\n", - colorRed, colorReset, - m.deleteTarget.Name, humanizeBytes(m.deleteTarget.Size), - colorGray, colorReset) - } - return b.String() -} - func (m *model) clampEntrySelection() { if len(m.entries) == 0 { m.selected = 0 @@ -1256,29 +918,3 @@ func scanOverviewPathCmd(path string, index int) tea.Cmd { } } } - -// calculateViewport dynamically calculates the viewport size based on terminal height -func calculateViewport(termHeight int, isLargeFiles bool) int { - if termHeight <= 0 { - // Terminal height unknown, use default - return defaultViewport - } - - // Calculate reserved space for UI elements - reserved := 6 // header (3-4 lines) + footer (2 lines) - if isLargeFiles { - reserved = 5 // Large files view has less overhead - } - - available := termHeight - reserved - - // Ensure minimum and maximum bounds - if available < 1 { - return 1 // Minimum 1 line for very short terminals - } - if available > 30 { - return 30 // Maximum 30 lines to avoid information overload - } - - return available -} diff --git a/cmd/analyze/view.go b/cmd/analyze/view.go new file mode 100644 index 0000000..f5c9109 --- /dev/null +++ b/cmd/analyze/view.go @@ -0,0 +1,374 @@ +//go:build darwin + +package main + +import ( + "fmt" + "strings" + "sync/atomic" +) + +// View renders the TUI display. +func (m model) View() string { + var b strings.Builder + fmt.Fprintln(&b) + + if m.inOverviewMode() { + fmt.Fprintf(&b, "%sAnalyze Disk%s\n", colorPurpleBold, colorReset) + if m.overviewScanning { + // Check if we're in initial scan (all entries are pending) + allPending := true + for _, entry := range m.entries { + if entry.Size >= 0 { + allPending = false + break + } + } + + if allPending { + // Show prominent loading screen for initial scan + fmt.Fprintf(&b, "%s%s%s%s Analyzing disk usage, please wait...%s\n", + colorCyan, colorBold, + spinnerFrames[m.spinner], + colorReset, colorReset) + return b.String() + } else { + // Progressive scanning - show subtle indicator + fmt.Fprintf(&b, "%sSelect a location to explore:%s ", colorGray, colorReset) + fmt.Fprintf(&b, "%s%s%s%s Scanning...\n\n", colorCyan, colorBold, spinnerFrames[m.spinner], colorReset) + } + } else { + // Check if there are still pending items + hasPending := false + for _, entry := range m.entries { + if entry.Size < 0 { + hasPending = true + break + } + } + if hasPending { + fmt.Fprintf(&b, "%sSelect a location to explore:%s ", colorGray, colorReset) + fmt.Fprintf(&b, "%s%s%s%s Scanning...\n\n", colorCyan, colorBold, spinnerFrames[m.spinner], colorReset) + } else { + fmt.Fprintf(&b, "%sSelect a location to explore:%s\n\n", colorGray, colorReset) + } + } + } else { + fmt.Fprintf(&b, "%sAnalyze Disk%s %s%s%s", colorPurpleBold, colorReset, colorGray, displayPath(m.path), colorReset) + if !m.scanning { + fmt.Fprintf(&b, " | Total: %s", humanizeBytes(m.totalSize)) + } + fmt.Fprintf(&b, "\n\n") + } + + if m.deleting { + // Show delete progress + count := int64(0) + if m.deleteCount != nil { + count = atomic.LoadInt64(m.deleteCount) + } + + fmt.Fprintf(&b, "%s%s%s%s Deleting: %s%s items%s removed, please wait...\n", + colorCyan, colorBold, + spinnerFrames[m.spinner], + colorReset, + colorYellow, formatNumber(count), colorReset) + + return b.String() + } + + if m.scanning { + filesScanned, dirsScanned, bytesScanned := m.getScanProgress() + + fmt.Fprintf(&b, "%s%s%s%s Scanning: %s%s files%s, %s%s dirs%s, %s%s%s\n", + colorCyan, colorBold, + spinnerFrames[m.spinner], + colorReset, + colorYellow, formatNumber(filesScanned), colorReset, + colorYellow, formatNumber(dirsScanned), colorReset, + colorGreen, humanizeBytes(bytesScanned), colorReset) + + if m.currentPath != nil { + currentPath := *m.currentPath + if currentPath != "" { + shortPath := displayPath(currentPath) + shortPath = truncateMiddle(shortPath, 50) + fmt.Fprintf(&b, "%s%s%s\n", colorGray, shortPath, colorReset) + } + } + + return b.String() + } + + if m.showLargeFiles { + if len(m.largeFiles) == 0 { + fmt.Fprintln(&b, " No large files found (>=100MB)") + } else { + viewport := calculateViewport(m.height, true) + start := m.largeOffset + if start < 0 { + start = 0 + } + end := start + viewport + if end > len(m.largeFiles) { + end = len(m.largeFiles) + } + maxLargeSize := int64(1) + for _, file := range m.largeFiles { + if file.Size > maxLargeSize { + maxLargeSize = file.Size + } + } + for idx := start; idx < end; idx++ { + file := m.largeFiles[idx] + shortPath := displayPath(file.Path) + shortPath = truncateMiddle(shortPath, 35) + paddedPath := padName(shortPath, 35) + entryPrefix := " " + nameColor := "" + sizeColor := colorGray + numColor := "" + if idx == m.largeSelected { + entryPrefix = fmt.Sprintf(" %s%sโ–ถ%s ", colorCyan, colorBold, colorReset) + nameColor = colorCyan + sizeColor = colorCyan + numColor = colorCyan + } + size := humanizeBytes(file.Size) + bar := coloredProgressBar(file.Size, maxLargeSize, 0) + fmt.Fprintf(&b, "%s%s%2d.%s %s | ๐Ÿ“„ %s%s%s %s%10s%s\n", + entryPrefix, numColor, idx+1, colorReset, bar, nameColor, paddedPath, colorReset, sizeColor, size, colorReset) + } + } + } else { + if len(m.entries) == 0 { + fmt.Fprintln(&b, " Empty directory") + } else { + if m.inOverviewMode() { + maxSize := int64(1) + for _, entry := range m.entries { + if entry.Size > maxSize { + maxSize = entry.Size + } + } + totalSize := m.totalSize + for idx, entry := range m.entries { + icon := "๐Ÿ“" + sizeVal := entry.Size + barValue := sizeVal + if barValue < 0 { + barValue = 0 + } + var percent float64 + if totalSize > 0 && sizeVal >= 0 { + percent = float64(sizeVal) / float64(totalSize) * 100 + } else { + percent = 0 + } + percentStr := fmt.Sprintf("%5.1f%%", percent) + if totalSize == 0 || sizeVal < 0 { + percentStr = " -- " + } + bar := coloredProgressBar(barValue, maxSize, percent) + sizeText := "pending.." + if sizeVal >= 0 { + sizeText = humanizeBytes(sizeVal) + } + sizeColor := colorGray + if sizeVal >= 0 && totalSize > 0 { + switch { + case percent >= 50: + sizeColor = colorRed + case percent >= 20: + sizeColor = colorYellow + case percent >= 5: + sizeColor = colorBlue + default: + sizeColor = colorGray + } + } + entryPrefix := " " + name := trimName(entry.Name) + paddedName := padName(name, 28) + nameSegment := fmt.Sprintf("%s %s", icon, paddedName) + numColor := "" + percentColor := "" + if idx == m.selected { + entryPrefix = fmt.Sprintf(" %s%sโ–ถ%s ", colorCyan, colorBold, colorReset) + nameSegment = fmt.Sprintf("%s%s %s%s", colorCyan, icon, paddedName, colorReset) + numColor = colorCyan + percentColor = colorCyan + sizeColor = colorCyan + } + displayIndex := idx + 1 + + // Priority: cleanable > unused time + var hintLabel string + if entry.IsDir && isCleanableDir(entry.Path) { + hintLabel = fmt.Sprintf("%s๐Ÿงน%s", colorYellow, colorReset) + } else { + // For overview mode, get access time on-demand if not set + lastAccess := entry.LastAccess + if lastAccess.IsZero() && entry.Path != "" { + lastAccess = getLastAccessTime(entry.Path) + } + if unusedTime := formatUnusedTime(lastAccess); unusedTime != "" { + hintLabel = fmt.Sprintf("%s%s%s", colorGray, unusedTime, colorReset) + } + } + + if hintLabel == "" { + fmt.Fprintf(&b, "%s%s%2d.%s %s %s%s%s | %s %s%10s%s\n", + entryPrefix, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset, + nameSegment, sizeColor, sizeText, colorReset) + } else { + fmt.Fprintf(&b, "%s%s%2d.%s %s %s%s%s | %s %s%10s%s %s\n", + entryPrefix, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset, + nameSegment, sizeColor, sizeText, colorReset, hintLabel) + } + } + } else { + // Normal mode with sizes and progress bars + maxSize := int64(1) + for _, entry := range m.entries { + if entry.Size > maxSize { + maxSize = entry.Size + } + } + + viewport := calculateViewport(m.height, false) + start := m.offset + if start < 0 { + start = 0 + } + end := start + viewport + if end > len(m.entries) { + end = len(m.entries) + } + + for idx := start; idx < end; idx++ { + entry := m.entries[idx] + icon := "๐Ÿ“„" + if entry.IsDir { + icon = "๐Ÿ“" + } + size := humanizeBytes(entry.Size) + name := trimName(entry.Name) + paddedName := padName(name, 28) + + // Calculate percentage + percent := float64(entry.Size) / float64(m.totalSize) * 100 + percentStr := fmt.Sprintf("%5.1f%%", percent) + + // Get colored progress bar + bar := coloredProgressBar(entry.Size, maxSize, percent) + + // Color the size based on magnitude + var sizeColor string + if percent >= 50 { + sizeColor = colorRed + } else if percent >= 20 { + sizeColor = colorYellow + } else if percent >= 5 { + sizeColor = colorBlue + } else { + sizeColor = colorGray + } + + // Keep chart columns aligned even when arrow is shown + entryPrefix := " " + nameSegment := fmt.Sprintf("%s %s", icon, paddedName) + numColor := "" + percentColor := "" + if idx == m.selected { + entryPrefix = fmt.Sprintf(" %s%sโ–ถ%s ", colorCyan, colorBold, colorReset) + nameSegment = fmt.Sprintf("%s%s %s%s", colorCyan, icon, paddedName, colorReset) + numColor = colorCyan + percentColor = colorCyan + sizeColor = colorCyan + } + + displayIndex := idx + 1 + + // Priority: cleanable > unused time + var hintLabel string + if entry.IsDir && isCleanableDir(entry.Path) { + hintLabel = fmt.Sprintf("%s๐Ÿงน%s", colorYellow, colorReset) + } else { + // Get access time on-demand if not set + lastAccess := entry.LastAccess + if lastAccess.IsZero() && entry.Path != "" { + lastAccess = getLastAccessTime(entry.Path) + } + if unusedTime := formatUnusedTime(lastAccess); unusedTime != "" { + hintLabel = fmt.Sprintf("%s%s%s", colorGray, unusedTime, colorReset) + } + } + + if hintLabel == "" { + fmt.Fprintf(&b, "%s%s%2d.%s %s %s%s%s | %s %s%10s%s\n", + entryPrefix, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset, + nameSegment, sizeColor, size, colorReset) + } else { + fmt.Fprintf(&b, "%s%s%2d.%s %s %s%s%s | %s %s%10s%s %s\n", + entryPrefix, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset, + nameSegment, sizeColor, size, colorReset, hintLabel) + } + } + } + } + } + + fmt.Fprintln(&b) + if m.inOverviewMode() { + // Show โ† Back if there's history (entered from a parent directory) + if len(m.history) > 0 { + fmt.Fprintf(&b, "%sโ†‘โ†“โ†โ†’ | Enter | R Refresh | O Open | F Show | โ† Back | Q Quit%s\n", colorGray, colorReset) + } else { + fmt.Fprintf(&b, "%sโ†‘โ†“โ†’ | Enter | R Refresh | O Open | F Show | Q Quit%s\n", colorGray, colorReset) + } + } else if m.showLargeFiles { + fmt.Fprintf(&b, "%sโ†‘โ†“โ† | R Refresh | O Open | F Show | โŒซ Delete | โ† Back | Q Quit%s\n", colorGray, colorReset) + } else { + largeFileCount := len(m.largeFiles) + if largeFileCount > 0 { + fmt.Fprintf(&b, "%sโ†‘โ†“โ†โ†’ | Enter | R Refresh | O Open | F Show | โŒซ Delete | T Top(%d) | Q Quit%s\n", colorGray, largeFileCount, colorReset) + } else { + fmt.Fprintf(&b, "%sโ†‘โ†“โ†โ†’ | Enter | R Refresh | O Open | F Show | โŒซ Delete | Q Quit%s\n", colorGray, colorReset) + } + } + if m.deleteConfirm && m.deleteTarget != nil { + fmt.Fprintln(&b) + fmt.Fprintf(&b, "%sDelete:%s %s (%s) %sPress โŒซ again | ESC cancel%s\n", + colorRed, colorReset, + m.deleteTarget.Name, humanizeBytes(m.deleteTarget.Size), + colorGray, colorReset) + } + return b.String() +} + +// calculateViewport computes the number of visible items based on terminal height. +func calculateViewport(termHeight int, isLargeFiles bool) int { + if termHeight <= 0 { + // Terminal height unknown, use default + return defaultViewport + } + + // Calculate reserved space for UI elements + reserved := 6 // header (3-4 lines) + footer (2 lines) + if isLargeFiles { + reserved = 5 // Large files view has less overhead + } + + available := termHeight - reserved + + // Ensure minimum and maximum bounds + if available < 1 { + return 1 // Minimum 1 line for very short terminals + } + if available > 30 { + return 30 // Maximum 30 lines to avoid information overload + } + + return available +} diff --git a/lib/check/all.sh b/lib/check/all.sh index 7afbd4d..9aac02d 100644 --- a/lib/check/all.sh +++ b/lib/check/all.sh @@ -567,7 +567,7 @@ check_cache_size() { for cache_path in "${cache_paths[@]}"; do if [[ -d "$cache_path" ]]; then local size_output - size_output=$(du -sk "$cache_path" 2> /dev/null | awk 'NR==1 {print $1}' | tr -d '[:space:]' || echo "") + size_output=$(get_path_size_kb "$cache_path") [[ "$size_output" =~ ^[0-9]+$ ]] || size_output=0 cache_size_kb=$((cache_size_kb + size_output)) fi diff --git a/lib/clean/app_caches.sh b/lib/clean/app_caches.sh index 6d62cf1..973a62b 100644 --- a/lib/clean/app_caches.sh +++ b/lib/clean/app_caches.sh @@ -106,7 +106,7 @@ clean_media_players() { has_offline_music=true elif [[ -d "$spotify_cache" ]]; then local cache_size_kb - cache_size_kb=$(du -sk "$spotify_cache" 2> /dev/null | awk '{print $1}' || echo "0") + cache_size_kb=$(get_path_size_kb "$spotify_cache") # Large cache (>500MB) likely contains offline music if [[ $cache_size_kb -ge 512000 ]]; then has_offline_music=true diff --git a/lib/clean/apps.sh b/lib/clean/apps.sh index 54ee6d4..74774f4 100644 --- a/lib/clean/apps.sh +++ b/lib/clean/apps.sh @@ -232,7 +232,7 @@ clean_orphaned_app_data() { if is_orphaned "$bundle_id" "$match"; then # Use timeout to prevent du from hanging on large/problematic directories local size_kb - size_kb=$(run_with_timeout 2 du -sk "$match" 2> /dev/null | awk '{print $1}' || echo "0") + size_kb=$(run_with_timeout 2 get_path_size_kb "$match") if [[ -z "$size_kb" || "$size_kb" == "0" ]]; then continue fi diff --git a/lib/clean/caches.sh b/lib/clean/caches.sh index 46170e8..0e597b9 100644 --- a/lib/clean/caches.sh +++ b/lib/clean/caches.sh @@ -75,7 +75,7 @@ clean_service_worker_cache() { # Pattern matches: letters/numbers, hyphens, then dot, then TLD # Example: "abc123_https_example.com_0" โ†’ "example.com" local domain=$(basename "$cache_dir" | grep -oE '[a-zA-Z0-9][-a-zA-Z0-9]*\.[a-zA-Z]{2,}' | head -1 || echo "") - local size=$(du -sk "$cache_dir" 2> /dev/null | awk '{print $1}') + local size=$(get_path_size_kb "$cache_dir") # Check if domain is protected local is_protected=false diff --git a/lib/clean/maintenance.sh b/lib/clean/maintenance.sh index 54cfc89..e5959ea 100644 --- a/lib/clean/maintenance.sh +++ b/lib/clean/maintenance.sh @@ -40,7 +40,7 @@ clean_broken_preferences() { # Validate plist using plutil if ! plutil -lint "$plist_file" > /dev/null 2>&1; then local size_kb - size_kb=$(du -sk "$plist_file" 2> /dev/null | awk '{print $1}' || echo "0") + size_kb=$(get_path_size_kb "$plist_file") if [[ "$DRY_RUN" != "true" ]]; then rm -f "$plist_file" 2> /dev/null || true @@ -67,7 +67,7 @@ clean_broken_preferences() { if ! plutil -lint "$plist_file" > /dev/null 2>&1; then local size_kb - size_kb=$(du -sk "$plist_file" 2> /dev/null | awk '{print $1}' || echo "0") + size_kb=$(get_path_size_kb "$plist_file") if [[ "$DRY_RUN" != "true" ]]; then rm -f "$plist_file" 2> /dev/null || true @@ -143,7 +143,7 @@ clean_broken_login_items() { # Program doesn't exist - this is a broken login item local size_kb - size_kb=$(du -sk "$plist_file" 2> /dev/null | awk '{print $1}' || echo "0") + size_kb=$(get_path_size_kb "$plist_file") if [[ "$DRY_RUN" != "true" ]]; then # Unload first if loaded diff --git a/lib/clean/system.sh b/lib/clean/system.sh index 2eba57b..03a2692 100644 --- a/lib/clean/system.sh +++ b/lib/clean/system.sh @@ -123,7 +123,7 @@ clean_time_machine_failed_backups() { continue fi - local size_kb=$(du -sk "$inprogress_file" 2> /dev/null | awk '{print $1}' || echo "0") + local size_kb=$(get_path_size_kb "$inprogress_file") if [[ "$size_kb" -gt 0 ]]; then local backup_name=$(basename "$inprogress_file") @@ -175,7 +175,7 @@ clean_time_machine_failed_backups() { continue fi - local size_kb=$(du -sk "$inprogress_file" 2> /dev/null | awk '{print $1}' || echo "0") + local size_kb=$(get_path_size_kb "$inprogress_file") if [[ "$size_kb" -gt 0 ]]; then local backup_name=$(basename "$inprogress_file") diff --git a/lib/clean/user.sh b/lib/clean/user.sh index e739f25..836ad40 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -273,7 +273,7 @@ clean_application_support_logs() { check_ios_device_backups() { local backup_dir="$HOME/Library/Application Support/MobileSync/Backup" if [[ -d "$backup_dir" ]] && find "$backup_dir" -mindepth 1 -maxdepth 1 | read -r _; then - local backup_kb=$(du -sk "$backup_dir" 2> /dev/null | awk '{print $1}') + local backup_kb=$(get_path_size_kb "$backup_dir") if [[ -n "${backup_kb:-}" && "$backup_kb" -gt 102400 ]]; then local backup_human=$(du -sh "$backup_dir" 2> /dev/null | awk '{print $1}') note_activity diff --git a/lib/core/common.sh b/lib/core/common.sh index 1075bb5..bcbcdd0 100755 --- a/lib/core/common.sh +++ b/lib/core/common.sh @@ -78,6 +78,13 @@ is_sip_enabled() { fi } +# Check if running in interactive terminal +# Returns: 0 if interactive (stdout is a terminal), 1 otherwise +# Usage: if is_interactive; then echo "Interactive mode"; fi +is_interactive() { + [[ -t 1 ]] +} + # Get spinner characters (overridable via MO_SPINNER_CHARS) mo_spinner_chars() { local chars="${MO_SPINNER_CHARS:-|/-\\}" @@ -1131,6 +1138,15 @@ clean_tool_cache() { # ============================================================================ # Size helpers # ============================================================================ + +# Get path size in KB using du +# Args: $1 - path to measure +# Returns: size in KB, or 0 if path doesn't exist or error occurs +get_path_size_kb() { + local path="$1" + du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0" +} + bytes_to_human_kb() { bytes_to_human "$((${1:-0} * 1024))"; } # ============================================================================ @@ -2064,7 +2080,7 @@ calculate_total_size() { while IFS= read -r file; do if [[ -n "$file" && -e "$file" ]]; then local size_kb - size_kb=$(du -sk "$file" 2> /dev/null | awk '{print $1}' || echo "0") + size_kb=$(get_path_size_kb "$file") ((total_kb += size_kb)) fi done <<< "$files" diff --git a/lib/optimize/tasks.sh b/lib/optimize/tasks.sh index d65b2a0..ead607b 100644 --- a/lib/optimize/tasks.sh +++ b/lib/optimize/tasks.sh @@ -3,15 +3,6 @@ set -euo pipefail -_opt_get_dir_size_kb() { - local path="$1" - [[ -e "$path" ]] || { - echo 0 - return - } - du -sk "$path" 2> /dev/null | awk '{print $1}' || echo 0 -} - # System maintenance: rebuild databases and flush caches opt_system_maintenance() { echo -e "${BLUE}${ICON_ARROW}${NC} Rebuilding LaunchServices database..." @@ -204,7 +195,7 @@ opt_mail_downloads() { local total_kb=0 for target_path in "${mail_dirs[@]}"; do - total_kb=$((total_kb + $(_opt_get_dir_size_kb "$target_path"))) + total_kb=$((total_kb + $(get_path_size_kb "$target_path"))) done if [[ $total_kb -lt $MOLE_MAIL_DOWNLOADS_MIN_KB ]]; then @@ -498,12 +489,6 @@ get_uptime_days() { } # Get directory size in KB -dir_size_kb() { - local path="$1" - [[ ! -e "$path" ]] && echo "0" && return - du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0" -} - # Format size from KB format_size_kb() { local kb="$1" @@ -525,7 +510,7 @@ format_size_kb() { # Check cache size check_cache_refresh() { local cache_dir="$HOME/Library/Caches" - local size_kb=$(dir_size_kb "$cache_dir") + local size_kb=$(get_path_size_kb "$cache_dir") local desc="Refresh Finder previews, Quick Look, and Safari caches" if [[ $size_kb -gt 0 ]]; then @@ -545,7 +530,7 @@ check_mail_downloads() { local total_kb=0 for dir in "${dirs[@]}"; do - total_kb=$((total_kb + $(dir_size_kb "$dir"))) + total_kb=$((total_kb + $(get_path_size_kb "$dir"))) done if [[ $total_kb -gt 0 ]]; then @@ -557,7 +542,7 @@ check_mail_downloads() { # Check saved state check_saved_state() { local state_dir="$HOME/Library/Saved Application State" - local size_kb=$(dir_size_kb "$state_dir") + local size_kb=$(get_path_size_kb "$state_dir") if [[ $size_kb -gt 0 ]]; then local size_str=$(format_size_kb "$size_kb") @@ -605,7 +590,7 @@ check_developer_cleanup() { local total_kb=0 for dir in "${dirs[@]}"; do - total_kb=$((total_kb + $(dir_size_kb "$dir"))) + total_kb=$((total_kb + $(get_path_size_kb "$dir"))) done if [[ $total_kb -gt 0 ]]; then diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index 9ce0547..764be3c 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -129,7 +129,7 @@ batch_uninstall_applications() { fi # Calculate size for summary (including system files) - local app_size_kb=$(du -sk "$app_path" 2> /dev/null | awk '{print $1}' || echo "0") + 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 system_files=$(find_app_system_files "$bundle_id" "$app_name") diff --git a/mole b/mole index 4630548..a29cf5b 100755 --- a/mole +++ b/mole @@ -22,7 +22,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/lib/core/common.sh" # Version info -VERSION="1.11.12" +VERSION="1.11.13" MOLE_TAGLINE="can dig deep to clean your Mac." # Check if Touch ID is already configured diff --git a/mole-analyze b/mole-analyze deleted file mode 100755 index 16a70ab..0000000 Binary files a/mole-analyze and /dev/null differ diff --git a/tests/optimization_tasks.bats b/tests/optimization_tasks.bats index fec6538..4ee90a0 100644 --- a/tests/optimization_tasks.bats +++ b/tests/optimization_tasks.bats @@ -179,12 +179,11 @@ EOF [ "$status" -eq 0 ] } -@test "_opt_get_dir_size_kb returns zero for missing directory" { +@test "get_path_size_kb returns zero for missing directory" { 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/optimize/tasks.sh" -size=$(_opt_get_dir_size_kb "/nonexistent/path") +size=$(get_path_size_kb "/nonexistent/path") echo "$size" EOF @@ -192,15 +191,14 @@ EOF [ "$output" = "0" ] } -@test "_opt_get_dir_size_kb calculates directory size" { +@test "get_path_size_kb calculates directory size" { mkdir -p "$HOME/test_size" dd if=/dev/zero of="$HOME/test_size/file.dat" bs=1024 count=10 2>/dev/null 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/optimize/tasks.sh" -size=$(_opt_get_dir_size_kb "$HOME/test_size") +size=$(get_path_size_kb "$HOME/test_size") echo "$size" EOF