1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-04 15:04:42 +00:00

feat: Enhance clean, optimize, analyze, and status commands, and update security audit documentation.

This commit is contained in:
Tw93
2025-12-31 16:23:31 +08:00
parent 8ac59da0e2
commit 9aa569cbb6
53 changed files with 538 additions and 1659 deletions

View File

@@ -75,11 +75,6 @@ func TestScanPathConcurrentBasic(t *testing.T) {
if bytes := atomic.LoadInt64(&bytesScanned); bytes == 0 {
t.Fatalf("expected byte counter to increase")
}
// current path update is throttled, so it might be empty for small scans
// if current == "" {
// t.Fatalf("expected current path to be updated")
// }
foundSymlink := false
for _, entry := range result.Entries {
if strings.HasSuffix(entry.Name, " →") {
@@ -148,7 +143,7 @@ func TestOverviewStoreAndLoad(t *testing.T) {
t.Fatalf("snapshot mismatch: want %d, got %d", want, got)
}
// Force reload from disk and ensure value persists.
// Reload from disk and ensure value persists.
resetOverviewSnapshotForTest()
got, err = loadStoredOverviewSize(path)
if err != nil {
@@ -220,7 +215,7 @@ func TestMeasureOverviewSize(t *testing.T) {
t.Fatalf("expected positive size, got %d", size)
}
// Ensure snapshot stored
// Ensure snapshot stored.
cached, err := loadStoredOverviewSize(target)
if err != nil {
t.Fatalf("loadStoredOverviewSize: %v", err)
@@ -279,13 +274,13 @@ func TestLoadCacheExpiresWhenDirectoryChanges(t *testing.T) {
t.Fatalf("saveCacheToDisk: %v", err)
}
// Touch directory to advance mtime beyond grace period.
// Advance mtime beyond grace period.
time.Sleep(time.Millisecond * 10)
if err := os.Chtimes(target, time.Now(), time.Now()); err != nil {
t.Fatalf("chtimes: %v", err)
}
// Force modtime difference beyond grace window by simulating an older cache entry.
// Simulate older cache entry to exceed grace window.
cachePath, err := getCachePath(target)
if err != nil {
t.Fatalf("getCachePath: %v", err)
@@ -335,24 +330,24 @@ func TestScanPathPermissionError(t *testing.T) {
t.Fatalf("create locked dir: %v", err)
}
// Create a file inside before locking, just to be sure
// Create a file before locking.
if err := os.WriteFile(filepath.Join(lockedDir, "secret.txt"), []byte("shh"), 0o644); err != nil {
t.Fatalf("write secret: %v", err)
}
// Remove permissions
// Remove permissions.
if err := os.Chmod(lockedDir, 0o000); err != nil {
t.Fatalf("chmod 000: %v", err)
}
defer func() {
// Restore permissions so cleanup can work
// Restore permissions for cleanup.
_ = os.Chmod(lockedDir, 0o755)
}()
var files, dirs, bytes int64
current := ""
// Scanning the locked dir itself should fail
// Scanning the locked dir itself should fail.
_, err := scanPathConcurrent(lockedDir, &files, &dirs, &bytes, &current)
if err == nil {
t.Fatalf("expected error scanning locked directory, got nil")

View File

@@ -222,7 +222,7 @@ func loadCacheFromDisk(path string) (*cacheEntry, error) {
}
if info.ModTime().After(entry.ModTime) {
// Only expire cache if the directory has been newer for longer than the grace window.
// Allow grace window.
if cacheModTimeGrace <= 0 || info.ModTime().Sub(entry.ModTime) > cacheModTimeGrace {
return nil, fmt.Errorf("cache expired: directory modified")
}
@@ -290,29 +290,23 @@ func removeOverviewSnapshot(path string) {
}
}
// prefetchOverviewCache scans overview directories in background
// to populate cache for faster overview mode access
// prefetchOverviewCache warms overview cache in background.
func prefetchOverviewCache(ctx context.Context) {
entries := createOverviewEntries()
// Check which entries need refresh
var needScan []string
for _, entry := range entries {
// Skip if we have fresh cache
if size, err := loadStoredOverviewSize(entry.Path); err == nil && size > 0 {
continue
}
needScan = append(needScan, entry.Path)
}
// Nothing to scan
if len(needScan) == 0 {
return
}
// Scan and cache in background with context cancellation support
for _, path := range needScan {
// Check if context is cancelled
select {
case <-ctx.Done():
return

View File

@@ -5,23 +5,20 @@ import (
"strings"
)
// isCleanableDir checks if a directory is safe to manually delete
// but NOT cleaned by mo clean (so user might want to delete it manually)
// isCleanableDir marks paths safe to delete manually (not handled by mo clean).
func isCleanableDir(path string) bool {
if path == "" {
return false
}
// Exclude paths that mo clean will handle automatically
// These are system caches/logs that mo clean already processes
// Exclude paths mo clean already handles.
if isHandledByMoClean(path) {
return false
}
baseName := filepath.Base(path)
// Only mark project dependencies and build outputs
// These are safe to delete but mo clean won't touch them
// Project dependencies and build outputs are safe.
if projectDependencyDirs[baseName] {
return true
}
@@ -29,9 +26,8 @@ func isCleanableDir(path string) bool {
return false
}
// isHandledByMoClean checks if this path will be cleaned by mo clean
// isHandledByMoClean checks if a path is cleaned by mo clean.
func isHandledByMoClean(path string) bool {
// Paths that mo clean handles (from clean.sh)
cleanPaths := []string{
"/Library/Caches/",
"/Library/Logs/",
@@ -49,16 +45,15 @@ func isHandledByMoClean(path string) bool {
return false
}
// Project dependency and build directories
// These are safe to delete manually but mo clean won't touch them
// Project dependency and build directories.
var projectDependencyDirs = map[string]bool{
// JavaScript/Node dependencies
"node_modules": true,
// JavaScript/Node.
"node_modules": true,
"bower_components": true,
".yarn": true, // Yarn local cache
".pnpm-store": true, // pnpm store
".yarn": true,
".pnpm-store": true,
// Python dependencies and outputs
// Python.
"venv": true,
".venv": true,
"virtualenv": true,
@@ -68,18 +63,18 @@ var projectDependencyDirs = map[string]bool{
".ruff_cache": true,
".tox": true,
".eggs": true,
"htmlcov": true, // Coverage reports
".ipynb_checkpoints": true, // Jupyter checkpoints
"htmlcov": true,
".ipynb_checkpoints": true,
// Ruby dependencies
// Ruby.
"vendor": true,
".bundle": true,
// Java/Kotlin/Scala
".gradle": true, // Project-level Gradle cache
"out": true, // IntelliJ IDEA build output
// Java/Kotlin/Scala.
".gradle": true,
"out": true,
// Build outputs (can be rebuilt)
// Build outputs.
"build": true,
"dist": true,
"target": true,
@@ -88,25 +83,25 @@ var projectDependencyDirs = map[string]bool{
".output": true,
".parcel-cache": true,
".turbo": true,
".vite": true, // Vite cache
".nx": true, // Nx cache
".vite": true,
".nx": true,
"coverage": true,
".coverage": true,
".nyc_output": true, // NYC coverage
".nyc_output": true,
// Frontend framework outputs
".angular": true, // Angular CLI cache
".svelte-kit": true, // SvelteKit build
".astro": true, // Astro cache
".docusaurus": true, // Docusaurus build
// Frontend framework outputs.
".angular": true,
".svelte-kit": true,
".astro": true,
".docusaurus": true,
// iOS/macOS development
// Apple dev.
"DerivedData": true,
"Pods": true,
".build": true,
"Carthage": true,
".dart_tool": true,
// Other tools
".terraform": true, // Terraform plugins
// Other tools.
".terraform": true,
}

View File

@@ -6,35 +6,35 @@ const (
maxEntries = 30
maxLargeFiles = 30
barWidth = 24
minLargeFileSize = 100 << 20 // 100 MB
defaultViewport = 12 // Default viewport when terminal height is unknown
overviewCacheTTL = 7 * 24 * time.Hour // 7 days
minLargeFileSize = 100 << 20
defaultViewport = 12
overviewCacheTTL = 7 * 24 * time.Hour
overviewCacheFile = "overview_sizes.json"
duTimeout = 30 * time.Second // Fail faster to fallback to concurrent scan
duTimeout = 30 * time.Second
mdlsTimeout = 5 * time.Second
maxConcurrentOverview = 8 // Increased parallel overview scans
batchUpdateSize = 100 // Batch atomic updates every N items
cacheModTimeGrace = 30 * time.Minute // Ignore minor directory mtime bumps
maxConcurrentOverview = 8
batchUpdateSize = 100
cacheModTimeGrace = 30 * time.Minute
// Worker pool configuration
minWorkers = 16 // Safe baseline for older machines
maxWorkers = 64 // Cap at 64 to avoid OS resource contention
cpuMultiplier = 4 // Balanced CPU usage
maxDirWorkers = 32 // Limit concurrent subdirectory scans
openCommandTimeout = 10 * time.Second // Timeout for open/reveal commands
// Worker pool limits.
minWorkers = 16
maxWorkers = 64
cpuMultiplier = 4
maxDirWorkers = 32
openCommandTimeout = 10 * time.Second
)
var foldDirs = map[string]bool{
// Version control
// VCS.
".git": true,
".svn": true,
".hg": true,
// JavaScript/Node
// JavaScript/Node.
"node_modules": true,
".npm": true,
"_npx": true, // ~/.npm/_npx global cache
"_cacache": true, // ~/.npm/_cacache
"_npx": true,
"_cacache": true,
"_logs": true,
"_locks": true,
"_quick": true,
@@ -56,7 +56,7 @@ var foldDirs = map[string]bool{
".bun": true,
".deno": true,
// Python
// Python.
"__pycache__": true,
".pytest_cache": true,
".mypy_cache": true,
@@ -73,7 +73,7 @@ var foldDirs = map[string]bool{
".pip": true,
".pipx": true,
// Ruby/Go/PHP (vendor), Java/Kotlin/Scala/Rust (target)
// Ruby/Go/PHP (vendor), Java/Kotlin/Scala/Rust (target).
"vendor": true,
".bundle": true,
"gems": true,
@@ -88,20 +88,20 @@ var foldDirs = map[string]bool{
".composer": true,
".cargo": true,
// Build outputs
// Build outputs.
"build": true,
"dist": true,
".output": true,
"coverage": true,
".coverage": true,
// IDE
// IDE.
".idea": true,
".vscode": true,
".vs": true,
".fleet": true,
// Cache directories
// Cache directories.
".cache": true,
"__MACOSX": true,
".DS_Store": true,
@@ -121,18 +121,18 @@ var foldDirs = map[string]bool{
".sdkman": true,
".nvm": true,
// macOS specific
// macOS.
"Application Scripts": true,
"Saved Application State": true,
// iCloud
// iCloud.
"Mobile Documents": true,
// Docker & Containers
// Containers.
".docker": true,
".containerd": true,
// Mobile development
// Mobile development.
"Pods": true,
"DerivedData": true,
".build": true,
@@ -140,18 +140,18 @@ var foldDirs = map[string]bool{
"Carthage": true,
".dart_tool": true,
// Web frameworks
// Web frameworks.
".angular": true,
".svelte-kit": true,
".astro": true,
".solid": true,
// Databases
// Databases.
".mysql": true,
".postgres": true,
"mongodb": true,
// Other
// Other.
".terraform": true,
".vagrant": true,
"tmp": true,
@@ -170,22 +170,22 @@ var skipSystemDirs = map[string]bool{
"bin": true,
"etc": true,
"var": true,
"opt": false, // User might want to specific check opt
"usr": false, // User might check usr
"Volumes": true, // Skip external drives by default when scanning root
"Network": true, // Skip network mounts
"opt": false,
"usr": false,
"Volumes": true,
"Network": true,
".vol": true,
".Spotlight-V100": true,
".fseventsd": true,
".DocumentRevisions-V100": true,
".TemporaryItems": true,
".MobileBackups": true, // Time Machine local snapshots
".MobileBackups": true,
}
var defaultSkipDirs = map[string]bool{
"nfs": true, // Network File System
"PHD": true, // Parallels Shared Folders / Home Directories
"Permissions": true, // Common macOS deny folder
"nfs": true,
"PHD": true,
"Permissions": true,
}
var skipExtensions = map[string]bool{

View File

@@ -23,13 +23,13 @@ func deletePathCmd(path string, counter *int64) tea.Cmd {
}
}
// deleteMultiplePathsCmd deletes multiple paths and returns combined results
// deleteMultiplePathsCmd deletes paths and aggregates results.
func deleteMultiplePathsCmd(paths []string, counter *int64) tea.Cmd {
return func() tea.Msg {
var totalCount int64
var errors []string
// Delete deeper paths first to avoid parent removal triggering child not-exist errors
// Delete deeper paths first to avoid parent/child conflicts.
pathsToDelete := append([]string(nil), paths...)
sort.Slice(pathsToDelete, func(i, j int) bool {
return strings.Count(pathsToDelete[i], string(filepath.Separator)) > strings.Count(pathsToDelete[j], string(filepath.Separator))
@@ -40,7 +40,7 @@ func deleteMultiplePathsCmd(paths []string, counter *int64) tea.Cmd {
totalCount += count
if err != nil {
if os.IsNotExist(err) {
continue // Parent already removed - not an actionable error
continue
}
errors = append(errors, err.Error())
}
@@ -51,17 +51,16 @@ func deleteMultiplePathsCmd(paths []string, counter *int64) tea.Cmd {
resultErr = &multiDeleteError{errors: errors}
}
// Return empty path to trigger full refresh since multiple items were deleted
return deleteProgressMsg{
done: true,
err: resultErr,
count: totalCount,
path: "", // Empty path signals multiple deletions
path: "",
}
}
}
// multiDeleteError holds multiple deletion errors
// multiDeleteError holds multiple deletion errors.
type multiDeleteError struct {
errors []string
}
@@ -79,14 +78,13 @@ func deletePathWithProgress(root string, counter *int64) (int64, error) {
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
// Skip permission errors but continue walking
// Skip permission errors but continue.
if os.IsPermission(err) {
if firstErr == nil {
firstErr = err
}
return filepath.SkipDir
}
// For other errors, record and continue
if firstErr == nil {
firstErr = err
}
@@ -100,7 +98,6 @@ func deletePathWithProgress(root string, counter *int64) (int64, error) {
atomic.StoreInt64(counter, count)
}
} else if firstErr == nil {
// Record first deletion error
firstErr = removeErr
}
}
@@ -108,19 +105,15 @@ func deletePathWithProgress(root string, counter *int64) (int64, error) {
return nil
})
// Track walk error separately
if err != nil && firstErr == nil {
firstErr = err
}
// Try to remove remaining directory structure
// Even if this fails, we still report files deleted
if removeErr := os.RemoveAll(root); removeErr != nil {
if firstErr == nil {
firstErr = removeErr
}
}
// Always return count (even if there were errors), along with first error
return count, firstErr
}

View File

@@ -11,9 +11,7 @@ func TestDeleteMultiplePathsCmdHandlesParentChild(t *testing.T) {
parent := filepath.Join(base, "parent")
child := filepath.Join(parent, "child")
// Create structure:
// parent/fileA
// parent/child/fileC
// Structure: parent/fileA, parent/child/fileC.
if err := os.MkdirAll(child, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}

View File

@@ -18,7 +18,7 @@ func displayPath(path string) string {
return path
}
// truncateMiddle truncates string in the middle, keeping head and tail.
// truncateMiddle trims the middle, keeping head and tail.
func truncateMiddle(s string, maxWidth int) string {
runes := []rune(s)
currentWidth := displayWidth(s)
@@ -27,9 +27,7 @@ func truncateMiddle(s string, maxWidth int) string {
return s
}
// Reserve 3 width for "..."
if maxWidth < 10 {
// Simple truncation for very small width
width := 0
for i, r := range runes {
width += runeWidth(r)
@@ -40,11 +38,9 @@ func truncateMiddle(s string, maxWidth int) string {
return s
}
// Keep more of the tail (filename usually more important)
targetHeadWidth := (maxWidth - 3) / 3
targetTailWidth := maxWidth - 3 - targetHeadWidth
// Find head cutoff point based on display width
headWidth := 0
headIdx := 0
for i, r := range runes {
@@ -56,7 +52,6 @@ func truncateMiddle(s string, maxWidth int) string {
headIdx = i + 1
}
// Find tail cutoff point
tailWidth := 0
tailIdx := len(runes)
for i := len(runes) - 1; i >= 0; i-- {
@@ -108,7 +103,6 @@ func coloredProgressBar(value, max int64, percent float64) string {
filled = barWidth
}
// Choose color based on percentage
var barColor string
if percent >= 50 {
barColor = colorRed
@@ -142,7 +136,7 @@ func coloredProgressBar(value, max int64, percent float64) string {
return bar + colorReset
}
// Calculate display width considering CJK characters and Emoji.
// runeWidth returns display width for wide characters and emoji.
func runeWidth(r rune) int {
if r >= 0x4E00 && r <= 0x9FFF || // CJK Unified Ideographs
r >= 0x3400 && r <= 0x4DBF || // CJK Extension A
@@ -173,18 +167,16 @@ func displayWidth(s string) int {
return width
}
// calculateNameWidth computes the optimal name column width based on terminal width.
// Fixed elements: prefix(3) + num(3) + bar(24) + percent(7) + sep(5) + icon(3) + size(12) + hint(4) = 61
// calculateNameWidth computes name column width from terminal width.
func calculateNameWidth(termWidth int) int {
const fixedWidth = 61
available := termWidth - fixedWidth
// Constrain to reasonable bounds
if available < 24 {
return 24 // Minimum for readability
return 24
}
if available > 60 {
return 60 // Maximum to avoid overly wide columns
return 60
}
return available
}
@@ -233,7 +225,7 @@ func padName(name string, targetWidth int) string {
return name + strings.Repeat(" ", targetWidth-currentWidth)
}
// formatUnusedTime formats the time since last access in a compact way.
// formatUnusedTime formats time since last access.
func formatUnusedTime(lastAccess time.Time) string {
if lastAccess.IsZero() {
return ""

View File

@@ -168,7 +168,6 @@ func TestTruncateMiddle(t *testing.T) {
}
func TestDisplayPath(t *testing.T) {
// This test assumes HOME is set
tests := []struct {
name string
setup func() string

View File

@@ -1,15 +1,10 @@
package main
// entryHeap implements heap.Interface for a min-heap of dirEntry (sorted by Size)
// Since we want Top N Largest, we use a Min Heap of size N.
// When adding a new item:
// 1. If heap size < N: push
// 2. If heap size == N and item > min (root): pop min, push item
// The heap will thus maintain the largest N items.
// entryHeap is a min-heap of dirEntry used to keep Top N largest entries.
type entryHeap []dirEntry
func (h entryHeap) Len() int { return len(h) }
func (h entryHeap) Less(i, j int) bool { return h[i].Size < h[j].Size } // Min-heap based on Size
func (h entryHeap) Less(i, j int) bool { return h[i].Size < h[j].Size }
func (h entryHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *entryHeap) Push(x interface{}) {
@@ -24,7 +19,7 @@ func (h *entryHeap) Pop() interface{} {
return x
}
// largeFileHeap implements heap.Interface for fileEntry
// largeFileHeap is a min-heap for fileEntry.
type largeFileHeap []fileEntry
func (h largeFileHeap) Len() int { return len(h) }

View File

@@ -130,7 +130,6 @@ func main() {
var isOverview bool
if target == "" {
// Default to overview mode
isOverview = true
abs = "/"
} else {
@@ -143,8 +142,7 @@ func main() {
isOverview = false
}
// Prefetch overview cache in background (non-blocking)
// Use context with timeout to prevent hanging
// Warm overview cache in background.
prefetchCtx, prefetchCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer prefetchCancel()
go prefetchOverviewCache(prefetchCtx)
@@ -184,7 +182,6 @@ func newModel(path string, isOverview bool) model {
largeMultiSelected: make(map[string]bool),
}
// In overview mode, create shortcut entries
if isOverview {
m.scanning = false
m.hydrateOverviewEntries()
@@ -205,12 +202,10 @@ func createOverviewEntries() []dirEntry {
home := os.Getenv("HOME")
entries := []dirEntry{}
// Separate Home and ~/Library for better visibility and performance
// Home excludes Library to avoid duplicate scanning
// Separate Home and ~/Library to avoid double counting.
if home != "" {
entries = append(entries, dirEntry{Name: "Home", Path: home, IsDir: true, Size: -1})
// Add ~/Library separately so users can see app data usage
userLibrary := filepath.Join(home, "Library")
if _, err := os.Stat(userLibrary); err == nil {
entries = append(entries, dirEntry{Name: "App Library", Path: userLibrary, IsDir: true, Size: -1})
@@ -222,7 +217,7 @@ func createOverviewEntries() []dirEntry {
dirEntry{Name: "System Library", Path: "/Library", IsDir: true, Size: -1},
)
// Add Volumes shortcut only when it contains real mounted folders (e.g., external disks)
// Include Volumes only when real mounts exist.
if hasUsefulVolumeMounts("/Volumes") {
entries = append(entries, dirEntry{Name: "Volumes", Path: "/Volumes", IsDir: true, Size: -1})
}
@@ -238,7 +233,6 @@ func hasUsefulVolumeMounts(path string) bool {
for _, entry := range entries {
name := entry.Name()
// Skip hidden control entries for Spotlight/TimeMachine etc.
if strings.HasPrefix(name, ".") {
continue
}
@@ -276,8 +270,7 @@ func (m *model) hydrateOverviewEntries() {
}
func (m *model) sortOverviewEntriesBySize() {
// Sort entries by size (largest first)
// Use stable sort to maintain order when sizes are equal
// Stable sort by size.
sort.SliceStable(m.entries, func(i, j int) bool {
return m.entries[i].Size > m.entries[j].Size
})
@@ -288,7 +281,6 @@ func (m *model) scheduleOverviewScans() tea.Cmd {
return nil
}
// Find pending entries (not scanned and not currently scanning)
var pendingIndices []int
for i, entry := range m.entries {
if entry.Size < 0 && !m.overviewScanningSet[entry.Path] {
@@ -299,18 +291,15 @@ func (m *model) scheduleOverviewScans() tea.Cmd {
}
}
// No more work to do
if len(pendingIndices) == 0 {
m.overviewScanning = false
if !hasPendingOverviewEntries(m.entries) {
// All scans complete - sort entries by size (largest first)
m.sortOverviewEntriesBySize()
m.status = "Ready"
}
return nil
}
// Mark all as scanning
var cmds []tea.Cmd
for _, idx := range pendingIndices {
entry := m.entries[idx]
@@ -361,7 +350,6 @@ func (m model) Init() tea.Cmd {
func (m model) scanCmd(path string) tea.Cmd {
return func() tea.Msg {
// Try to load from persistent cache first
if cached, err := loadCacheFromDisk(path); err == nil {
result := scanResult{
Entries: cached.Entries,
@@ -371,8 +359,6 @@ func (m model) scanCmd(path string) tea.Cmd {
return scanResultMsg{result: result, err: nil}
}
// Use singleflight to avoid duplicate scans of the same path
// If multiple goroutines request the same path, only one scan will be performed
v, err, _ := scanGroup.Do(path, func() (interface{}, error) {
return scanPathConcurrent(path, m.filesScanned, m.dirsScanned, m.bytesScanned, m.currentPath)
})
@@ -383,10 +369,8 @@ func (m model) scanCmd(path string) tea.Cmd {
result := v.(scanResult)
// Save to persistent cache asynchronously with error logging
go func(p string, r scanResult) {
if err := saveCacheToDisk(p, r); err != nil {
// Log error but don't fail the scan
_ = err // Cache save failure is not critical
}
}(path, result)
@@ -412,7 +396,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case deleteProgressMsg:
if msg.done {
m.deleting = false
// Clear multi-selection after delete
m.multiSelected = make(map[string]bool)
m.largeMultiSelected = make(map[string]bool)
if msg.err != nil {
@@ -424,7 +407,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
invalidateCache(m.path)
m.status = fmt.Sprintf("Deleted %d items", msg.count)
// Mark all caches as dirty
for i := range m.history {
m.history[i].Dirty = true
}
@@ -433,9 +415,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
entry.Dirty = true
m.cache[path] = entry
}
// Refresh the view
m.scanning = true
// Reset scan counters for rescan
atomic.StoreInt64(m.filesScanned, 0)
atomic.StoreInt64(m.dirsScanned, 0)
atomic.StoreInt64(m.bytesScanned, 0)
@@ -452,7 +432,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.status = fmt.Sprintf("Scan failed: %v", msg.err)
return m, nil
}
// 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 {
@@ -477,7 +456,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case overviewSizeMsg:
// Remove from scanning set
delete(m.overviewScanningSet, msg.Path)
if msg.Err == nil {
@@ -488,7 +466,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if m.inOverviewMode() {
// Update entry with result
for i := range m.entries {
if m.entries[i].Path == msg.Path {
if msg.Err == nil {
@@ -501,18 +478,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.totalSize = sumKnownEntrySizes(m.entries)
// Show error briefly if any
if msg.Err != nil {
m.status = fmt.Sprintf("Unable to measure %s: %v", displayPath(msg.Path), msg.Err)
}
// Schedule next batch of scans
cmd := m.scheduleOverviewScans()
return m, cmd
}
return m, nil
case tickMsg:
// Keep spinner running if scanning or deleting or if there are pending overview items
hasPending := false
if m.inOverviewMode() {
for _, entry := range m.entries {
@@ -524,7 +498,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if m.scanning || m.deleting || (m.inOverviewMode() && (m.overviewScanning || hasPending)) {
m.spinner = (m.spinner + 1) % len(spinnerFrames)
// Update delete progress status
if m.deleting && m.deleteCount != nil {
count := atomic.LoadInt64(m.deleteCount)
if count > 0 {
@@ -540,18 +513,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Handle delete confirmation
// Delete confirm flow.
if m.deleteConfirm {
switch msg.String() {
case "delete", "backspace":
// Confirm delete - start async deletion
m.deleteConfirm = false
m.deleting = true
var deleteCount int64
m.deleteCount = &deleteCount
// Collect paths to delete (multi-select or single)
// Using paths instead of indices is safer - avoids deleting wrong files if list changes
// Collect paths (safer than indices).
var pathsToDelete []string
if m.showLargeFiles {
if len(m.largeMultiSelected) > 0 {
@@ -587,13 +558,11 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.status = fmt.Sprintf("Deleting %d items...", len(pathsToDelete))
return m, tea.Batch(deleteMultiplePathsCmd(pathsToDelete, m.deleteCount), tickCmd())
case "esc", "q":
// Cancel delete with ESC or Q
m.status = "Cancelled"
m.deleteConfirm = false
m.deleteTarget = nil
return m, nil
default:
// Ignore other keys - keep showing confirmation
return m, nil
}
}
@@ -648,7 +617,6 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
if len(m.history) == 0 {
// Return to overview if at top level
if !m.inOverviewMode() {
return m, m.switchToOverviewMode()
}
@@ -663,7 +631,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.largeOffset = last.LargeOffset
m.isOverview = last.IsOverview
if last.Dirty {
// If returning to overview mode, refresh overview entries instead of scanning
// On overview return, refresh cached entries.
if last.IsOverview {
m.hydrateOverviewEntries()
m.totalSize = sumKnownEntrySizes(m.entries)
@@ -696,17 +664,14 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.scanning = false
return m, nil
case "r":
// Clear multi-selection on refresh
m.multiSelected = make(map[string]bool)
m.largeMultiSelected = make(map[string]bool)
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
}
@@ -717,11 +682,9 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, tea.Batch(m.scheduleOverviewScans(), tickCmd())
}
// Normal mode: Invalidate cache before rescanning
invalidateCache(m.path)
m.status = "Refreshing..."
m.scanning = true
// Reset scan counters for refresh
atomic.StoreInt64(m.filesScanned, 0)
atomic.StoreInt64(m.dirsScanned, 0)
atomic.StoreInt64(m.bytesScanned, 0)
@@ -730,7 +693,6 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
return m, tea.Batch(m.scanCmd(m.path), tickCmd())
case "t", "T":
// Don't allow switching to large files view in overview mode
if !m.inOverviewMode() {
m.showLargeFiles = !m.showLargeFiles
if m.showLargeFiles {
@@ -740,16 +702,13 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} else {
m.multiSelected = make(map[string]bool)
}
// Reset status when switching views
m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize))
}
case "o":
// Open selected entries (multi-select aware)
// Limit batch operations to prevent system resource exhaustion
// Open selected entries (multi-select aware).
const maxBatchOpen = 20
if m.showLargeFiles {
if len(m.largeFiles) > 0 {
// Check for multi-selection first
if len(m.largeMultiSelected) > 0 {
count := len(m.largeMultiSelected)
if count > maxBatchOpen {
@@ -775,7 +734,6 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
}
} else if len(m.entries) > 0 {
// Check for multi-selection first
if len(m.multiSelected) > 0 {
count := len(m.multiSelected)
if count > maxBatchOpen {
@@ -801,12 +759,10 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
}
case "f", "F":
// Reveal selected entries in Finder (multi-select aware)
// Limit batch operations to prevent system resource exhaustion
// Reveal in Finder (multi-select aware).
const maxBatchReveal = 20
if m.showLargeFiles {
if len(m.largeFiles) > 0 {
// Check for multi-selection first
if len(m.largeMultiSelected) > 0 {
count := len(m.largeMultiSelected)
if count > maxBatchReveal {
@@ -832,7 +788,6 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
}
} else if len(m.entries) > 0 {
// Check for multi-selection first
if len(m.multiSelected) > 0 {
count := len(m.multiSelected)
if count > maxBatchReveal {
@@ -858,8 +813,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
}
case " ":
// Toggle multi-select with spacebar
// Using paths as keys (instead of indices) is safer and more maintainable
// Toggle multi-select (paths as keys).
if m.showLargeFiles {
if len(m.largeFiles) > 0 && m.largeSelected < len(m.largeFiles) {
if m.largeMultiSelected == nil {
@@ -871,11 +825,9 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} else {
m.largeMultiSelected[selectedPath] = true
}
// Update status to show selection count and total size
count := len(m.largeMultiSelected)
if count > 0 {
var totalSize int64
// Calculate total size by looking up each selected path
for path := range m.largeMultiSelected {
for _, file := range m.largeFiles {
if file.Path == path {
@@ -899,11 +851,9 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} else {
m.multiSelected[selectedPath] = true
}
// Update status to show selection count and total size
count := len(m.multiSelected)
if count > 0 {
var totalSize int64
// Calculate total size by looking up each selected path
for path := range m.multiSelected {
for _, entry := range m.entries {
if entry.Path == path {
@@ -918,15 +868,11 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
}
case "delete", "backspace":
// Delete selected file(s) or directory(ies)
if m.showLargeFiles {
if len(m.largeFiles) > 0 {
// Check for multi-selection first
if len(m.largeMultiSelected) > 0 {
m.deleteConfirm = true
// Set deleteTarget to first selected for display purposes
for path := range m.largeMultiSelected {
// Find the file entry by path
for _, file := range m.largeFiles {
if file.Path == path {
m.deleteTarget = &dirEntry{
@@ -952,12 +898,10 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
}
} else if len(m.entries) > 0 && !m.inOverviewMode() {
// Check for multi-selection first
if len(m.multiSelected) > 0 {
m.deleteConfirm = true
// Set deleteTarget to first selected for display purposes
for path := range m.multiSelected {
// Find the entry by path
// Resolve entry by path.
for i := range m.entries {
if m.entries[i].Path == path {
m.deleteTarget = &m.entries[i]
@@ -994,7 +938,6 @@ func (m *model) switchToOverviewMode() tea.Cmd {
m.status = "Ready"
return nil
}
// Start tick to animate spinner while scanning
return tea.Batch(cmd, tickCmd())
}
@@ -1004,7 +947,6 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) {
}
selected := m.entries[m.selected]
if selected.IsDir {
// Always save current state to history (including overview mode)
m.history = append(m.history, snapshotFromModel(m))
m.path = selected.Path
m.selected = 0
@@ -1012,11 +954,9 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) {
m.status = "Scanning..."
m.scanning = true
m.isOverview = false
// Clear multi-selection when entering new directory
m.multiSelected = make(map[string]bool)
m.largeMultiSelected = make(map[string]bool)
// Reset scan counters for new scan
atomic.StoreInt64(m.filesScanned, 0)
atomic.StoreInt64(m.dirsScanned, 0)
atomic.StoreInt64(m.bytesScanned, 0)

View File

@@ -31,16 +31,14 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
var total int64
// Use heaps to track Top N items, drastically reducing memory usage
// for directories with millions of files
// Keep Top N heaps.
entriesHeap := &entryHeap{}
heap.Init(entriesHeap)
largeFilesHeap := &largeFileHeap{}
heap.Init(largeFilesHeap)
// Use worker pool for concurrent directory scanning
// For I/O-bound operations, use more workers than CPU count
// Worker pool sized for I/O-bound scanning.
numWorkers := runtime.NumCPU() * cpuMultiplier
if numWorkers < minWorkers {
numWorkers = minWorkers
@@ -57,17 +55,15 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
sem := make(chan struct{}, numWorkers)
var wg sync.WaitGroup
// Use channels to collect results without lock contention
// Collect results via channels.
entryChan := make(chan dirEntry, len(children))
largeFileChan := make(chan fileEntry, maxLargeFiles*2)
// Start goroutines to collect from channels into heaps
var collectorWg sync.WaitGroup
collectorWg.Add(2)
go func() {
defer collectorWg.Done()
for entry := range entryChan {
// Maintain Top N Heap for entries
if entriesHeap.Len() < maxEntries {
heap.Push(entriesHeap, entry)
} else if entry.Size > (*entriesHeap)[0].Size {
@@ -79,7 +75,6 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
go func() {
defer collectorWg.Done()
for file := range largeFileChan {
// Maintain Top N Heap for large files
if largeFilesHeap.Len() < maxLargeFiles {
heap.Push(largeFilesHeap, file)
} else if file.Size > (*largeFilesHeap)[0].Size {
@@ -96,20 +91,15 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
for _, child := range children {
fullPath := filepath.Join(root, child.Name())
// Skip symlinks to avoid following them into unexpected locations
// Use Type() instead of IsDir() to check without following symlinks
// Skip symlinks to avoid following unexpected targets.
if child.Type()&fs.ModeSymlink != 0 {
// 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.
// Count link size only to avoid double-counting targets.
info, err := child.Info()
if err != nil {
continue
@@ -118,28 +108,26 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
atomic.AddInt64(&total, size)
entryChan <- dirEntry{
Name: child.Name() + " →", // Add arrow to indicate symlink
Name: child.Name() + " →",
Path: fullPath,
Size: size,
IsDir: isDir, // Allow navigation if target is directory
IsDir: isDir,
LastAccess: getLastAccessTimeFromInfo(info),
}
continue
}
if child.IsDir() {
// Check if directory should be skipped based on user configuration
if defaultSkipDirs[child.Name()] {
continue
}
// In root directory, skip system directories completely
// Skip system dirs at root.
if isRootDir && skipSystemDirs[child.Name()] {
continue
}
// Special handling for ~/Library - reuse cache to avoid duplicate scanning
// This is scanned separately in overview mode
// ~/Library is scanned separately; reuse cache when possible.
if isHomeDir && child.Name() == "Library" {
wg.Add(1)
go func(name, path string) {
@@ -148,14 +136,11 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
defer func() { <-sem }()
var size int64
// Try overview cache first (from overview scan)
if cached, err := loadStoredOverviewSize(path); err == nil && cached > 0 {
size = cached
} else if cached, err := loadCacheFromDisk(path); err == nil {
// Try disk cache
size = cached.TotalSize
} else {
// No cache available, scan normally
size = calculateDirSizeConcurrent(path, largeFileChan, filesScanned, dirsScanned, bytesScanned, currentPath)
}
atomic.AddInt64(&total, size)
@@ -172,7 +157,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
continue
}
// For folded directories, calculate size quickly without expanding
// Folded dirs: fast size without expanding.
if shouldFoldDirWithPath(child.Name(), fullPath) {
wg.Add(1)
go func(name, path string) {
@@ -180,10 +165,8 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
sem <- struct{}{}
defer func() { <-sem }()
// Try du command first for folded dirs (much faster)
size, err := getDirectorySizeFromDu(path)
if err != nil || size <= 0 {
// Fallback to concurrent walk if du fails
size = calculateDirSizeFast(path, filesScanned, dirsScanned, bytesScanned, currentPath)
}
atomic.AddInt64(&total, size)
@@ -194,13 +177,12 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
Path: path,
Size: size,
IsDir: true,
LastAccess: time.Time{}, // Lazy load when displayed
LastAccess: time.Time{},
}
}(child.Name(), fullPath)
continue
}
// Normal directory: full scan with detail
wg.Add(1)
go func(name, path string) {
defer wg.Done()
@@ -216,7 +198,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
Path: path,
Size: size,
IsDir: true,
LastAccess: time.Time{}, // Lazy load when displayed
LastAccess: time.Time{},
}
}(child.Name(), fullPath)
continue
@@ -226,7 +208,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
if err != nil {
continue
}
// Get actual disk usage for sparse files and cloud files
// Actual disk usage for sparse/cloud files.
size := getActualFileSize(fullPath, info)
atomic.AddInt64(&total, size)
atomic.AddInt64(filesScanned, 1)
@@ -239,7 +221,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
IsDir: false,
LastAccess: getLastAccessTimeFromInfo(info),
}
// Only track large files that are not code/text files
// Track large files only.
if !shouldSkipFileForLargeTracking(fullPath) && size >= minLargeFileSize {
largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}
}
@@ -247,12 +229,12 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
wg.Wait()
// Close channels and wait for collectors to finish
// Close channels and wait for collectors.
close(entryChan)
close(largeFileChan)
collectorWg.Wait()
// Convert Heaps to sorted slices (Descending order)
// Convert heaps to sorted slices (descending).
entries := make([]dirEntry, entriesHeap.Len())
for i := len(entries) - 1; i >= 0; i-- {
entries[i] = heap.Pop(entriesHeap).(dirEntry)
@@ -263,20 +245,11 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
largeFiles[i] = heap.Pop(largeFilesHeap).(fileEntry)
}
// Try to use Spotlight (mdfind) for faster large file discovery
// This is a performance optimization that gracefully falls back to scan results
// if Spotlight is unavailable or fails. The fallback is intentionally silent
// because users only care about correct results, not the method used.
// Use Spotlight for large files when available.
if spotlightFiles := findLargeFilesWithSpotlight(root, minLargeFileSize); len(spotlightFiles) > 0 {
// Spotlight results are already sorted top N
// Use them in place of scanned large files
largeFiles = spotlightFiles
}
// Double check sorting consistency (Spotlight returns sorted, but heap pop handles scan results)
// If needed, we could re-sort largeFiles, but heap pop ensures ascending, and we filled reverse, so it's Descending.
// Spotlight returns Descending. So no extra sort needed for either.
return scanResult{
Entries: entries,
LargeFiles: largeFiles,
@@ -285,21 +258,16 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
}
func shouldFoldDirWithPath(name, path string) bool {
// Check basic fold list first
if foldDirs[name] {
return true
}
// Special case: npm cache directories - fold all subdirectories
// This includes: .npm/_quick/*, .npm/_cacache/*, .npm/a-z/*, .tnpm/*
// Handle npm cache structure.
if strings.Contains(path, "/.npm/") || strings.Contains(path, "/.tnpm/") {
// Get the parent directory name
parent := filepath.Base(filepath.Dir(path))
// If parent is a cache folder (_quick, _cacache, etc) or npm dir itself, fold it
if parent == ".npm" || parent == ".tnpm" || strings.HasPrefix(parent, "_") {
return true
}
// Also fold single-letter subdirectories (npm cache structure like .npm/a/, .npm/b/)
if len(name) == 1 {
return true
}
@@ -313,17 +281,14 @@ func shouldSkipFileForLargeTracking(path string) bool {
return skipExtensions[ext]
}
// calculateDirSizeFast performs concurrent directory size calculation using os.ReadDir
// This is a faster fallback than filepath.WalkDir when du fails
// calculateDirSizeFast performs concurrent dir sizing using os.ReadDir.
func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *string) int64 {
var total int64
var wg sync.WaitGroup
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
// Limit total concurrency for this walk
concurrency := runtime.NumCPU() * 4
if concurrency > 64 {
concurrency = 64
@@ -351,19 +316,16 @@ func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned *
for _, entry := range entries {
if entry.IsDir() {
// Directories: recurse concurrently
wg.Add(1)
// Capture loop variable
subDir := filepath.Join(dirPath, entry.Name())
go func(p string) {
defer wg.Done()
sem <- struct{}{} // Acquire token
defer func() { <-sem }() // Release token
sem <- struct{}{}
defer func() { <-sem }()
walk(p)
}(subDir)
atomic.AddInt64(dirsScanned, 1)
} else {
// Files: process immediately
info, err := entry.Info()
if err == nil {
size := getActualFileSize(filepath.Join(dirPath, entry.Name()), info)
@@ -388,9 +350,8 @@ func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned *
return total
}
// Use Spotlight (mdfind) to quickly find large files in a directory
// Use Spotlight (mdfind) to quickly find large files.
func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
// mdfind query: files >= minSize in the specified directory
query := fmt.Sprintf("kMDItemFSSize >= %d", minSize)
ctx, cancel := context.WithTimeout(context.Background(), mdlsTimeout)
@@ -399,7 +360,6 @@ func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
cmd := exec.CommandContext(ctx, "mdfind", "-onlyin", root, query)
output, err := cmd.Output()
if err != nil {
// Fallback: mdfind not available or failed
return nil
}
@@ -411,28 +371,26 @@ func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
continue
}
// Filter out code files first (cheapest check, no I/O)
// Filter code files first (cheap).
if shouldSkipFileForLargeTracking(line) {
continue
}
// Filter out files in folded directories (cheap string check)
// Filter folded directories (cheap string check).
if isInFoldedDir(line) {
continue
}
// Use Lstat instead of Stat (faster, doesn't follow symlinks)
info, err := os.Lstat(line)
if err != nil {
continue
}
// Skip if it's a directory or symlink
if info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
continue
}
// Get actual disk usage for sparse files and cloud files
// Actual disk usage for sparse/cloud files.
actualSize := getActualFileSize(line, info)
files = append(files, fileEntry{
Name: filepath.Base(line),
@@ -441,12 +399,11 @@ func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
})
}
// Sort by size (descending)
// Sort by size (descending).
sort.Slice(files, func(i, j int) bool {
return files[i].Size > files[j].Size
})
// Return top N
if len(files) > maxLargeFiles {
files = files[:maxLargeFiles]
}
@@ -454,9 +411,8 @@ func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
return files
}
// isInFoldedDir checks if a path is inside a folded directory (optimized)
// isInFoldedDir checks if a path is inside a folded directory.
func isInFoldedDir(path string) bool {
// Split path into components for faster checking
parts := strings.Split(path, string(os.PathSeparator))
for _, part := range parts {
if foldDirs[part] {
@@ -467,7 +423,6 @@ func isInFoldedDir(path string) bool {
}
func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, filesScanned, dirsScanned, bytesScanned *int64, currentPath *string) int64 {
// Read immediate children
children, err := os.ReadDir(root)
if err != nil {
return 0
@@ -476,7 +431,7 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil
var total int64
var wg sync.WaitGroup
// Limit concurrent subdirectory scans to avoid too many goroutines
// Limit concurrent subdirectory scans.
maxConcurrent := runtime.NumCPU() * 2
if maxConcurrent > maxDirWorkers {
maxConcurrent = maxDirWorkers
@@ -486,9 +441,7 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil
for _, child := range children {
fullPath := filepath.Join(root, child.Name())
// Skip symlinks to avoid following them into unexpected locations
if child.Type()&fs.ModeSymlink != 0 {
// For symlinks, just count their size without following
info, err := child.Info()
if err != nil {
continue
@@ -501,9 +454,7 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil
}
if child.IsDir() {
// Check if this is a folded directory
if shouldFoldDirWithPath(child.Name(), fullPath) {
// Use du for folded directories (much faster)
wg.Add(1)
go func(path string) {
defer wg.Done()
@@ -517,7 +468,6 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil
continue
}
// Recursively scan subdirectory in parallel
wg.Add(1)
go func(path string) {
defer wg.Done()
@@ -531,7 +481,6 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil
continue
}
// Handle files
info, err := child.Info()
if err != nil {
continue
@@ -542,12 +491,11 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil
atomic.AddInt64(filesScanned, 1)
atomic.AddInt64(bytesScanned, size)
// Track large files
if !shouldSkipFileForLargeTracking(fullPath) && size >= minLargeFileSize {
largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}
}
// Update current path occasionally to prevent UI jitter
// Update current path occasionally to prevent UI jitter.
if currentPath != nil && atomic.LoadInt64(filesScanned)%int64(batchUpdateSize) == 0 {
*currentPath = fullPath
}

View File

@@ -8,7 +8,7 @@ import (
"sync/atomic"
)
// View renders the TUI display.
// View renders the TUI.
func (m model) View() string {
var b strings.Builder
fmt.Fprintln(&b)
@@ -16,7 +16,6 @@ func (m model) View() string {
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 {
@@ -26,19 +25,16 @@ func (m model) View() string {
}
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 {
@@ -62,7 +58,6 @@ func (m model) View() string {
}
if m.deleting {
// Show delete progress
count := int64(0)
if m.deleteCount != nil {
count = atomic.LoadInt64(m.deleteCount)
@@ -130,7 +125,6 @@ func (m model) View() string {
sizeColor := colorGray
numColor := ""
// Check if this item is multi-selected (by path, not index)
isMultiSelected := m.largeMultiSelected != nil && m.largeMultiSelected[file.Path]
selectIcon := "○"
if isMultiSelected {
@@ -164,8 +158,7 @@ func (m model) View() string {
}
}
totalSize := m.totalSize
// For overview mode, use a fixed small width since path names are short
// (~/Downloads, ~/Library, etc. - max ~15 chars)
// Overview paths are short; fixed width keeps layout stable.
nameWidth := 20
for idx, entry := range m.entries {
icon := "📁"
@@ -217,12 +210,10 @@ func (m model) View() string {
}
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)
@@ -243,7 +234,6 @@ func (m model) View() string {
}
}
} else {
// Normal mode with sizes and progress bars
maxSize := int64(1)
for _, entry := range m.entries {
if entry.Size > maxSize {
@@ -272,14 +262,11 @@ func (m model) View() string {
name := trimNameWithWidth(entry.Name, nameWidth)
paddedName := padName(name, nameWidth)
// 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
@@ -291,7 +278,6 @@ func (m model) View() string {
sizeColor = colorGray
}
// Check if this item is multi-selected (by path, not index)
isMultiSelected := m.multiSelected != nil && m.multiSelected[entry.Path]
selectIcon := "○"
nameColor := ""
@@ -300,7 +286,6 @@ func (m model) View() string {
nameColor = colorGreen
}
// Keep chart columns aligned even when arrow is shown
entryPrefix := " "
nameSegment := fmt.Sprintf("%s %s", icon, paddedName)
if nameColor != "" {
@@ -320,12 +305,10 @@ func (m model) View() string {
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)
@@ -351,7 +334,6 @@ func (m model) View() string {
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 File | ← Back | Q Quit%s\n", colorGray, colorReset)
} else {
@@ -383,12 +365,10 @@ func (m model) View() string {
}
if m.deleteConfirm && m.deleteTarget != nil {
fmt.Fprintln(&b)
// Show multi-selection delete info if applicable
var deleteCount int
var totalDeleteSize int64
if m.showLargeFiles && len(m.largeMultiSelected) > 0 {
deleteCount = len(m.largeMultiSelected)
// Calculate total size by looking up each selected path
for path := range m.largeMultiSelected {
for _, file := range m.largeFiles {
if file.Path == path {
@@ -399,7 +379,6 @@ func (m model) View() string {
}
} else if !m.showLargeFiles && len(m.multiSelected) > 0 {
deleteCount = len(m.multiSelected)
// Calculate total size by looking up each selected path
for path := range m.multiSelected {
for _, entry := range m.entries {
if entry.Path == path {
@@ -425,27 +404,24 @@ func (m model) View() string {
return b.String()
}
// calculateViewport computes the number of visible items based on terminal height.
// calculateViewport returns visible rows for the current 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)
reserved := 6 // Header + footer
if isLargeFiles {
reserved = 5 // Large files view has less overhead
reserved = 5
}
available := termHeight - reserved
// Ensure minimum and maximum bounds
if available < 1 {
return 1 // Minimum 1 line for very short terminals
return 1
}
if available > 30 {
return 30 // Maximum 30 lines to avoid information overload
return 30
}
return available

View File

@@ -72,7 +72,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.metrics = msg.data
m.lastUpdated = msg.data.CollectedAt
m.collecting = false
// Mark ready after first successful data collection
// Mark ready after first successful data collection.
if !m.ready {
m.ready = true
}
@@ -126,7 +126,7 @@ func animTick() tea.Cmd {
}
func animTickWithSpeed(cpuUsage float64) tea.Cmd {
// Higher CPU = faster animation (50ms to 300ms)
// Higher CPU = faster animation.
interval := 300 - int(cpuUsage*2.5)
if interval < 50 {
interval = 50

View File

@@ -141,16 +141,16 @@ type BluetoothDevice struct {
}
type Collector struct {
// Static Cache (Collected once at startup)
// Static cache.
cachedHW HardwareInfo
lastHWAt time.Time
hasStatic bool
// Slow Cache (Collected every 30s-1m)
// Slow cache (30s-1m).
lastBTAt time.Time
lastBT []BluetoothDevice
// Fast Metrics (Collected every 1 second)
// Fast metrics (1s).
prevNet map[string]net.IOCountersStat
lastNetAt time.Time
lastGPUAt time.Time
@@ -168,9 +168,7 @@ func NewCollector() *Collector {
func (c *Collector) Collect() (MetricsSnapshot, error) {
now := time.Now()
// Start host info collection early (it's fast but good to parallelize if possible,
// but it returns a struct needed for result, so we can just run it here or in parallel)
// host.Info is usually cached by gopsutil but let's just call it.
// Host info is cached by gopsutil; fetch once.
hostInfo, _ := host.Info()
var (
@@ -192,7 +190,7 @@ func (c *Collector) Collect() (MetricsSnapshot, error) {
topProcs []ProcessInfo
)
// Helper to launch concurrent collection
// Helper to launch concurrent collection.
collect := func(fn func() error) {
wg.Add(1)
go func() {
@@ -209,7 +207,7 @@ func (c *Collector) Collect() (MetricsSnapshot, error) {
}()
}
// Launch all independent collection tasks
// Launch independent collection tasks.
collect(func() (err error) { cpuStats, err = collectCPU(); return })
collect(func() (err error) { memStats, err = collectMemory(); return })
collect(func() (err error) { diskStats, err = collectDisks(); return })
@@ -221,7 +219,7 @@ func (c *Collector) Collect() (MetricsSnapshot, error) {
collect(func() (err error) { sensorStats, _ = collectSensors(); return nil })
collect(func() (err error) { gpuStats, err = c.collectGPU(now); return })
collect(func() (err error) {
// Bluetooth is slow, cache for 30s
// Bluetooth is slow; cache for 30s.
if now.Sub(c.lastBTAt) > 30*time.Second || len(c.lastBT) == 0 {
btStats = c.collectBluetooth(now)
c.lastBT = btStats
@@ -233,12 +231,11 @@ func (c *Collector) Collect() (MetricsSnapshot, error) {
})
collect(func() (err error) { topProcs = collectTopProcesses(); return nil })
// Wait for all to complete
// Wait for all to complete.
wg.Wait()
// Dependent tasks (must run after others)
// Dependent tasks (must run after others)
// Cache hardware info as it's expensive and rarely changes
// Dependent tasks (post-collect).
// Cache hardware info as it's expensive and rarely changes.
if !c.hasStatic || now.Sub(c.lastHWAt) > 10*time.Minute {
c.cachedHW = collectHardware(memStats.Total, diskStats)
c.lastHWAt = now
@@ -272,8 +269,6 @@ func (c *Collector) Collect() (MetricsSnapshot, error) {
}, mergeErr
}
// Utility functions
func runCmd(ctx context.Context, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
output, err := cmd.Output()
@@ -289,11 +284,9 @@ func commandExists(name string) bool {
}
defer func() {
if r := recover(); r != nil {
// If LookPath panics due to permissions or platform quirks, act as if the command is missing.
// Treat LookPath panics as "missing".
}
}()
_, err := exec.LookPath(name)
return err == nil
}
// humanBytes is defined in view.go to avoid duplication

View File

@@ -15,7 +15,7 @@ import (
)
var (
// Package-level cache for heavy system_profiler data
// Cache for heavy system_profiler output.
lastPowerAt time.Time
cachedPower string
powerCacheTTL = 30 * time.Second
@@ -24,15 +24,15 @@ var (
func collectBatteries() (batts []BatteryStatus, err error) {
defer func() {
if r := recover(); r != nil {
// Swallow panics from platform-specific battery probes to keep the UI alive.
// Swallow panics to keep UI alive.
err = fmt.Errorf("battery collection failed: %v", r)
}
}()
// macOS: pmset (fast, for real-time percentage/status)
// macOS: pmset for real-time percentage/status.
if runtime.GOOS == "darwin" && commandExists("pmset") {
if out, err := runCmd(context.Background(), "pmset", "-g", "batt"); err == nil {
// Get heavy info (health, cycles) from cached system_profiler
// Health/cycles from cached system_profiler.
health, cycles := getCachedPowerData()
if batts := parsePMSet(out, health, cycles); len(batts) > 0 {
return batts, nil
@@ -40,7 +40,7 @@ func collectBatteries() (batts []BatteryStatus, err error) {
}
}
// Linux: /sys/class/power_supply
// Linux: /sys/class/power_supply.
matches, _ := filepath.Glob("/sys/class/power_supply/BAT*/capacity")
for _, capFile := range matches {
statusFile := filepath.Join(filepath.Dir(capFile), "status")
@@ -73,9 +73,8 @@ func parsePMSet(raw string, health string, cycles int) []BatteryStatus {
var timeLeft string
for _, line := range lines {
// Check for time remaining
// Time remaining.
if strings.Contains(line, "remaining") {
// Extract time like "1:30 remaining"
parts := strings.Fields(line)
for i, p := range parts {
if p == "remaining" && i > 0 {
@@ -121,7 +120,7 @@ func parsePMSet(raw string, health string, cycles int) []BatteryStatus {
return out
}
// getCachedPowerData returns condition, cycles, and fan speed from cached system_profiler output.
// getCachedPowerData returns condition and cycles from cached system_profiler.
func getCachedPowerData() (health string, cycles int) {
out := getSystemPowerOutput()
if out == "" {
@@ -173,7 +172,7 @@ func collectThermal() ThermalStatus {
var thermal ThermalStatus
// Get fan info and adapter power from cached system_profiler
// Fan info from cached system_profiler.
out := getSystemPowerOutput()
if out != "" {
lines := strings.Split(out, "\n")
@@ -181,7 +180,6 @@ func collectThermal() ThermalStatus {
lower := strings.ToLower(line)
if strings.Contains(lower, "fan") && strings.Contains(lower, "speed") {
if _, after, found := strings.Cut(line, ":"); found {
// Extract number from string like "1200 RPM"
numStr := strings.TrimSpace(after)
numStr, _, _ = strings.Cut(numStr, " ")
thermal.FanSpeed, _ = strconv.Atoi(numStr)
@@ -190,7 +188,7 @@ func collectThermal() ThermalStatus {
}
}
// Get power metrics from ioreg (fast, real-time data)
// Power metrics from ioreg (fast, real-time).
ctxPower, cancelPower := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancelPower()
if out, err := runCmd(ctxPower, "ioreg", "-rn", "AppleSmartBattery"); err == nil {
@@ -198,8 +196,7 @@ func collectThermal() ThermalStatus {
for _, line := range lines {
line = strings.TrimSpace(line)
// Get battery temperature
// Matches: "Temperature" = 3055 (note: space before =)
// Battery temperature ("Temperature" = 3055).
if _, after, found := strings.Cut(line, "\"Temperature\" = "); found {
valStr := strings.TrimSpace(after)
if tempRaw, err := strconv.Atoi(valStr); err == nil && tempRaw > 0 {
@@ -207,13 +204,10 @@ func collectThermal() ThermalStatus {
}
}
// Get adapter power (Watts)
// Read from current adapter: "AdapterDetails" = {"Watts"=140...}
// Skip historical data: "AppleRawAdapterDetails" = ({Watts=90}, {Watts=140})
// Adapter power (Watts) from current adapter.
if strings.Contains(line, "\"AdapterDetails\" = {") && !strings.Contains(line, "AppleRaw") {
if _, after, found := strings.Cut(line, "\"Watts\"="); found {
valStr := strings.TrimSpace(after)
// Remove trailing characters like , or }
valStr, _, _ = strings.Cut(valStr, ",")
valStr, _, _ = strings.Cut(valStr, "}")
valStr = strings.TrimSpace(valStr)
@@ -223,8 +217,7 @@ func collectThermal() ThermalStatus {
}
}
// Get system power consumption (mW -> W)
// Matches: "SystemPowerIn"=12345
// System power consumption (mW -> W).
if _, after, found := strings.Cut(line, "\"SystemPowerIn\"="); found {
valStr := strings.TrimSpace(after)
valStr, _, _ = strings.Cut(valStr, ",")
@@ -235,8 +228,7 @@ func collectThermal() ThermalStatus {
}
}
// Get battery power (mW -> W, positive = discharging)
// Matches: "BatteryPower"=12345
// Battery power (mW -> W, positive = discharging).
if _, after, found := strings.Cut(line, "\"BatteryPower\"="); found {
valStr := strings.TrimSpace(after)
valStr, _, _ = strings.Cut(valStr, ",")
@@ -249,14 +241,13 @@ func collectThermal() ThermalStatus {
}
}
// Fallback: Try thermal level as a proxy if temperature not found
// Fallback: thermal level proxy.
if thermal.CPUTemp == 0 {
ctx2, cancel2 := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel2()
out2, err := runCmd(ctx2, "sysctl", "-n", "machdep.xcpm.cpu_thermal_level")
if err == nil {
level, _ := strconv.Atoi(strings.TrimSpace(out2))
// Estimate temp: level 0-100 roughly maps to 40-100°C
if level >= 0 {
thermal.CPUTemp = 45 + float64(level)*0.5
}

View File

@@ -80,7 +80,7 @@ func parseSPBluetooth(raw string) []BluetoothDevice {
continue
}
if !strings.HasPrefix(line, " ") && strings.HasSuffix(trim, ":") {
// Reset at top-level sections
// Reset at top-level sections.
currentName = ""
connected = false
battery = ""

View File

@@ -31,12 +31,9 @@ func collectCPU() (CPUStatus, error) {
logical = 1
}
// Use two-call pattern for more reliable CPU measurements
// First call: initialize/store current CPU times
// Two-call pattern for more reliable CPU usage.
cpu.Percent(0, true)
// Wait for sampling interval
time.Sleep(cpuSampleInterval)
// Second call: get actual percentages based on difference
percents, err := cpu.Percent(0, true)
var totalPercent float64
perCoreEstimated := false
@@ -69,7 +66,7 @@ func collectCPU() (CPUStatus, error) {
}
}
// Get P-core and E-core counts for Apple Silicon
// P/E core counts for Apple Silicon.
pCores, eCores := getCoreTopology()
return CPUStatus{
@@ -91,14 +88,13 @@ func isZeroLoad(avg load.AvgStat) bool {
}
var (
// Package-level cache for core topology
// Cache for core topology.
lastTopologyAt time.Time
cachedP, cachedE int
topologyTTL = 10 * time.Minute
)
// getCoreTopology returns P-core and E-core counts on Apple Silicon.
// Returns (0, 0) on non-Apple Silicon or if detection fails.
// getCoreTopology returns P/E core counts on Apple Silicon.
func getCoreTopology() (pCores, eCores int) {
if runtime.GOOS != "darwin" {
return 0, 0
@@ -114,7 +110,6 @@ func getCoreTopology() (pCores, eCores int) {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
// Get performance level info from sysctl
out, err := runCmd(ctx, "sysctl", "-n",
"hw.perflevel0.logicalcpu",
"hw.perflevel0.name",
@@ -129,15 +124,12 @@ func getCoreTopology() (pCores, eCores int) {
return 0, 0
}
// Parse perflevel0
level0Count, _ := strconv.Atoi(strings.TrimSpace(lines[0]))
level0Name := strings.ToLower(strings.TrimSpace(lines[1]))
// Parse perflevel1
level1Count, _ := strconv.Atoi(strings.TrimSpace(lines[2]))
level1Name := strings.ToLower(strings.TrimSpace(lines[3]))
// Assign based on name (Performance vs Efficiency)
if strings.Contains(level0Name, "performance") {
pCores = level0Count
} else if strings.Contains(level0Name, "efficiency") {

View File

@@ -43,7 +43,7 @@ func collectDisks() ([]DiskStatus, error) {
if strings.HasPrefix(part.Mountpoint, "/System/Volumes/") {
continue
}
// Skip private volumes
// Skip /private mounts.
if strings.HasPrefix(part.Mountpoint, "/private/") {
continue
}
@@ -58,12 +58,11 @@ func collectDisks() ([]DiskStatus, error) {
if err != nil || usage.Total == 0 {
continue
}
// Skip small volumes (< 1GB)
// Skip <1GB volumes.
if usage.Total < 1<<30 {
continue
}
// For APFS volumes, use a more precise dedup key (bytes level)
// to handle shared storage pools properly
// Use size-based dedupe key for shared pools.
volKey := fmt.Sprintf("%s:%d", part.Fstype, usage.Total)
if seenVolume[volKey] {
continue
@@ -94,7 +93,7 @@ func collectDisks() ([]DiskStatus, error) {
}
var (
// Package-level cache for external disk status
// External disk cache.
lastDiskCacheAt time.Time
diskTypeCache = make(map[string]bool)
diskCacheTTL = 2 * time.Minute
@@ -106,7 +105,7 @@ func annotateDiskTypes(disks []DiskStatus) {
}
now := time.Now()
// Clear cache if stale
// Clear stale cache.
if now.Sub(lastDiskCacheAt) > diskCacheTTL {
diskTypeCache = make(map[string]bool)
lastDiskCacheAt = now

View File

@@ -17,7 +17,7 @@ const (
powermetricsTimeout = 2 * time.Second
)
// Pre-compiled regex patterns for GPU usage parsing
// Regex for GPU usage parsing.
var (
gpuActiveResidencyRe = regexp.MustCompile(`GPU HW active residency:\s+([\d.]+)%`)
gpuIdleResidencyRe = regexp.MustCompile(`GPU idle residency:\s+([\d.]+)%`)
@@ -25,7 +25,7 @@ var (
func (c *Collector) collectGPU(now time.Time) ([]GPUStatus, error) {
if runtime.GOOS == "darwin" {
// Get static GPU info (cached for 10 min)
// Static GPU info (cached 10 min).
if len(c.cachedGPU) == 0 || c.lastGPUAt.IsZero() || now.Sub(c.lastGPUAt) >= macGPUInfoTTL {
if gpus, err := readMacGPUInfo(); err == nil && len(gpus) > 0 {
c.cachedGPU = gpus
@@ -33,12 +33,12 @@ func (c *Collector) collectGPU(now time.Time) ([]GPUStatus, error) {
}
}
// Get real-time GPU usage
// Real-time GPU usage.
if len(c.cachedGPU) > 0 {
usage := getMacGPUUsage()
result := make([]GPUStatus, len(c.cachedGPU))
copy(result, c.cachedGPU)
// Apply usage to first GPU (Apple Silicon has one integrated GPU)
// Apply usage to first GPU (Apple Silicon).
if len(result) > 0 {
result[0].Usage = usage
}
@@ -152,19 +152,18 @@ func readMacGPUInfo() ([]GPUStatus, error) {
return gpus, nil
}
// getMacGPUUsage gets GPU active residency from powermetrics.
// Returns -1 if unavailable (e.g., not running as root).
// getMacGPUUsage reads GPU active residency from powermetrics.
func getMacGPUUsage() float64 {
ctx, cancel := context.WithTimeout(context.Background(), powermetricsTimeout)
defer cancel()
// powermetrics requires root, but we try anyway - some systems may have it enabled
// powermetrics may require root.
out, err := runCmd(ctx, "powermetrics", "--samplers", "gpu_power", "-i", "500", "-n", "1")
if err != nil {
return -1
}
// Parse "GPU HW active residency: X.XX%"
// Parse "GPU HW active residency: X.XX%".
matches := gpuActiveResidencyRe.FindStringSubmatch(out)
if len(matches) >= 2 {
usage, err := strconv.ParseFloat(matches[1], 64)
@@ -173,7 +172,7 @@ func getMacGPUUsage() float64 {
}
}
// Fallback: parse "GPU idle residency: X.XX%" and calculate active
// Fallback: parse idle residency and derive active.
matchesIdle := gpuIdleResidencyRe.FindStringSubmatch(out)
if len(matchesIdle) >= 2 {
idle, err := strconv.ParseFloat(matchesIdle[1], 64)

View File

@@ -18,19 +18,18 @@ func collectHardware(totalRAM uint64, disks []DiskStatus) HardwareInfo {
}
}
// Get model and CPU from system_profiler
// Model and CPU from system_profiler.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var model, cpuModel, osVersion string
// Get hardware overview
out, err := runCmd(ctx, "system_profiler", "SPHardwareDataType")
if err == nil {
lines := strings.Split(out, "\n")
for _, line := range lines {
lower := strings.ToLower(strings.TrimSpace(line))
// Prefer "Model Name" over "Model Identifier"
// Prefer "Model Name" over "Model Identifier".
if strings.Contains(lower, "model name:") {
parts := strings.Split(line, ":")
if len(parts) == 2 {
@@ -52,7 +51,6 @@ func collectHardware(totalRAM uint64, disks []DiskStatus) HardwareInfo {
}
}
// Get macOS version
ctx2, cancel2 := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel2()
out2, err := runCmd(ctx2, "sw_vers", "-productVersion")
@@ -60,7 +58,6 @@ func collectHardware(totalRAM uint64, disks []DiskStatus) HardwareInfo {
osVersion = "macOS " + strings.TrimSpace(out2)
}
// Get disk size
diskSize := "Unknown"
if len(disks) > 0 {
diskSize = humanBytes(disks[0].Total)

View File

@@ -5,45 +5,43 @@ import (
"strings"
)
// Health score calculation weights and thresholds
// Health score weights and thresholds.
const (
// Weights (must sum to ~100 for total score)
// Weights.
healthCPUWeight = 30.0
healthMemWeight = 25.0
healthDiskWeight = 20.0
healthThermalWeight = 15.0
healthIOWeight = 10.0
// CPU thresholds
// CPU.
cpuNormalThreshold = 30.0
cpuHighThreshold = 70.0
// Memory thresholds
// Memory.
memNormalThreshold = 50.0
memHighThreshold = 80.0
memPressureWarnPenalty = 5.0
memPressureCritPenalty = 15.0
// Disk thresholds
// Disk.
diskWarnThreshold = 70.0
diskCritThreshold = 90.0
// Thermal thresholds
// Thermal.
thermalNormalThreshold = 60.0
thermalHighThreshold = 85.0
// Disk IO thresholds (MB/s)
// Disk IO (MB/s).
ioNormalThreshold = 50.0
ioHighThreshold = 150.0
)
func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, diskIO DiskIOStatus, thermal ThermalStatus) (int, string) {
// Start with perfect score
score := 100.0
issues := []string{}
// CPU Usage (30% weight) - deduct up to 30 points
// 0-30% CPU = 0 deduction, 30-70% = linear, 70-100% = heavy penalty
// CPU penalty.
cpuPenalty := 0.0
if cpu.Usage > cpuNormalThreshold {
if cpu.Usage > cpuHighThreshold {
@@ -57,8 +55,7 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
issues = append(issues, "High CPU")
}
// Memory Usage (25% weight) - deduct up to 25 points
// 0-50% = 0 deduction, 50-80% = linear, 80-100% = heavy penalty
// Memory penalty.
memPenalty := 0.0
if mem.UsedPercent > memNormalThreshold {
if mem.UsedPercent > memHighThreshold {
@@ -72,7 +69,7 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
issues = append(issues, "High Memory")
}
// Memory Pressure (extra penalty)
// Memory pressure penalty.
if mem.Pressure == "warn" {
score -= memPressureWarnPenalty
issues = append(issues, "Memory Pressure")
@@ -81,7 +78,7 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
issues = append(issues, "Critical Memory")
}
// Disk Usage (20% weight) - deduct up to 20 points
// Disk penalty.
diskPenalty := 0.0
if len(disks) > 0 {
diskUsage := disks[0].UsedPercent
@@ -98,7 +95,7 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
}
}
// Thermal (15% weight) - deduct up to 15 points
// Thermal penalty.
thermalPenalty := 0.0
if thermal.CPUTemp > 0 {
if thermal.CPUTemp > thermalNormalThreshold {
@@ -112,7 +109,7 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
score -= thermalPenalty
}
// Disk IO (10% weight) - deduct up to 10 points
// Disk IO penalty.
ioPenalty := 0.0
totalIO := diskIO.ReadRate + diskIO.WriteRate
if totalIO > ioNormalThreshold {
@@ -125,7 +122,7 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
}
score -= ioPenalty
// Ensure score is in valid range
// Clamp score.
if score < 0 {
score = 0
}
@@ -133,7 +130,7 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d
score = 100
}
// Generate message
// Build message.
msg := "Excellent"
if score >= 90 {
msg = "Excellent"

View File

@@ -17,7 +17,7 @@ func (c *Collector) collectNetwork(now time.Time) ([]NetworkStatus, error) {
return nil, err
}
// Get IP addresses for interfaces
// Map interface IPs.
ifAddrs := getInterfaceIPs()
if c.lastNetAt.IsZero() {
@@ -81,7 +81,7 @@ func getInterfaceIPs() map[string]string {
}
for _, iface := range ifaces {
for _, addr := range iface.Addrs {
// Only IPv4
// IPv4 only.
if strings.Contains(addr.Addr, ".") && !strings.HasPrefix(addr.Addr, "127.") {
ip := strings.Split(addr.Addr, "/")[0]
result[iface.Name] = ip
@@ -104,14 +104,14 @@ func isNoiseInterface(name string) bool {
}
func collectProxy() ProxyStatus {
// Check environment variables first
// 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
// Extract host.
host := val
if strings.Contains(host, "://") {
host = strings.SplitN(host, "://", 2)[1]
@@ -123,7 +123,7 @@ func collectProxy() ProxyStatus {
}
}
// macOS: check system proxy via scutil
// macOS: check system proxy via scutil.
if runtime.GOOS == "darwin" {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()

View File

@@ -15,7 +15,7 @@ func collectTopProcesses() []ProcessInfo {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
// Use ps to get top processes by CPU
// Use ps to get top processes by CPU.
out, err := runCmd(ctx, "ps", "-Aceo", "pcpu,pmem,comm", "-r")
if err != nil {
return nil
@@ -24,10 +24,10 @@ func collectTopProcesses() []ProcessInfo {
lines := strings.Split(strings.TrimSpace(out), "\n")
var procs []ProcessInfo
for i, line := range lines {
if i == 0 { // skip header
if i == 0 {
continue
}
if i > 5 { // top 5
if i > 5 {
break
}
fields := strings.Fields(line)
@@ -37,7 +37,7 @@ func collectTopProcesses() []ProcessInfo {
cpuVal, _ := strconv.ParseFloat(fields[0], 64)
memVal, _ := strconv.ParseFloat(fields[1], 64)
name := fields[len(fields)-1]
// Get just the process name without path
// Strip path from command name.
if idx := strings.LastIndex(name, "/"); idx >= 0 {
name = name[idx+1:]
}

View File

@@ -33,7 +33,7 @@ const (
iconProcs = "❊"
)
// Check if it's Christmas season (Dec 10-31)
// isChristmasSeason reports Dec 10-31.
func isChristmasSeason() bool {
now := time.Now()
month := now.Month()
@@ -41,7 +41,7 @@ func isChristmasSeason() bool {
return month == time.December && day >= 10 && day <= 31
}
// Mole body frames (legs animate)
// Mole body frames.
var moleBody = [][]string{
{
` /\_/\`,
@@ -69,7 +69,7 @@ var moleBody = [][]string{
},
}
// Mole body frames with Christmas hat
// Mole body frames with Christmas hat.
var moleBodyWithHat = [][]string{
{
` *`,
@@ -105,7 +105,7 @@ var moleBodyWithHat = [][]string{
},
}
// Generate frames with horizontal movement
// getMoleFrame renders the animated mole.
func getMoleFrame(animFrame int, termWidth int) string {
var body []string
var bodyIdx int
@@ -119,15 +119,12 @@ func getMoleFrame(animFrame int, termWidth int) string {
body = moleBody[bodyIdx]
}
// Calculate mole width (approximate)
moleWidth := 15
// Move across terminal width
maxPos := termWidth - moleWidth
if maxPos < 0 {
maxPos = 0
}
// Move position: 0 -> maxPos -> 0
cycleLength := maxPos * 2
if cycleLength == 0 {
cycleLength = 1
@@ -141,7 +138,6 @@ func getMoleFrame(animFrame int, termWidth int) string {
var lines []string
if isChristmas {
// Render with red hat on first 3 lines
for i, line := range body {
if i < 3 {
lines = append(lines, padding+hatStyle.Render(line))
@@ -165,27 +161,24 @@ type cardData struct {
}
func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int) string {
// Title
title := titleStyle.Render("Mole Status")
// Health Score
scoreStyle := getScoreStyle(m.HealthScore)
scoreText := subtleStyle.Render("Health ") + scoreStyle.Render(fmt.Sprintf("● %d", m.HealthScore))
// Hardware info - compact for single line
// Hardware info for a single line.
infoParts := []string{}
if m.Hardware.Model != "" {
infoParts = append(infoParts, primaryStyle.Render(m.Hardware.Model))
}
if m.Hardware.CPUModel != "" {
cpuInfo := m.Hardware.CPUModel
// Add GPU core count if available (compact format)
// Append GPU core count when available.
if len(m.GPU) > 0 && m.GPU[0].CoreCount > 0 {
cpuInfo += fmt.Sprintf(" (%dGPU)", m.GPU[0].CoreCount)
}
infoParts = append(infoParts, cpuInfo)
}
// Combine RAM and Disk to save space
var specs []string
if m.Hardware.TotalRAM != "" {
specs = append(specs, m.Hardware.TotalRAM)
@@ -200,10 +193,8 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int
infoParts = append(infoParts, m.Hardware.OSVersion)
}
// Single line compact header
headerLine := title + " " + scoreText + " " + subtleStyle.Render(strings.Join(infoParts, " · "))
// Running mole animation
mole := getMoleFrame(animFrame, termWidth)
if errMsg != "" {
@@ -214,19 +205,14 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int
func getScoreStyle(score int) lipgloss.Style {
if score >= 90 {
// Excellent - Bright Green
return lipgloss.NewStyle().Foreground(lipgloss.Color("#87FF87")).Bold(true)
} else if score >= 75 {
// Good - Green
return lipgloss.NewStyle().Foreground(lipgloss.Color("#87D787")).Bold(true)
} else if score >= 60 {
// Fair - Yellow
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")).Bold(true)
} else if score >= 40 {
// Poor - Orange
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFAF5F")).Bold(true)
} else {
// Critical - Red
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Bold(true)
}
}
@@ -240,7 +226,6 @@ func buildCards(m MetricsSnapshot, _ int) []cardData {
renderProcessCard(m.TopProcesses),
renderNetworkCard(m.Network, m.Proxy),
}
// Only show sensors if we have valid temperature readings
if hasSensorData(m.Sensors) {
cards = append(cards, renderSensorsCard(m.Sensors))
}
@@ -334,7 +319,7 @@ func renderMemoryCard(mem MemoryStatus) cardData {
} else {
lines = append(lines, fmt.Sprintf("Swap %s", subtleStyle.Render("not in use")))
}
// Memory pressure
// Memory pressure status.
if mem.Pressure != "" {
pressureStyle := okStyle
pressureText := "Status " + mem.Pressure
@@ -405,7 +390,6 @@ func formatDiskLine(label string, d DiskStatus) string {
}
func ioBar(rate float64) string {
// Scale: 0-50 MB/s maps to 0-5 blocks
filled := int(rate / 10.0)
if filled > 5 {
filled = 5
@@ -441,7 +425,7 @@ func renderProcessCard(procs []ProcessInfo) cardData {
}
func miniBar(percent float64) string {
filled := int(percent / 20) // 5 chars max for 100%
filled := int(percent / 20)
if filled > 5 {
filled = 5
}
@@ -471,7 +455,7 @@ func renderNetworkCard(netStats []NetworkStatus, proxy ProxyStatus) cardData {
txBar := netBar(totalTx)
lines = append(lines, fmt.Sprintf("Down %s %s", rxBar, formatRate(totalRx)))
lines = append(lines, fmt.Sprintf("Up %s %s", txBar, formatRate(totalTx)))
// Show proxy and IP in one line
// Show proxy and IP on one line.
var infoParts []string
if proxy.Enabled {
infoParts = append(infoParts, "Proxy "+proxy.Type)
@@ -487,7 +471,6 @@ func renderNetworkCard(netStats []NetworkStatus, proxy ProxyStatus) cardData {
}
func netBar(rate float64) string {
// Scale: 0-10 MB/s maps to 0-5 blocks
filled := int(rate / 2.0)
if filled > 5 {
filled = 5
@@ -511,8 +494,6 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
lines = append(lines, subtleStyle.Render("No battery"))
} else {
b := batts[0]
// Line 1: label + bar + percentage (consistent with other cards)
// Only show red when battery is critically low
statusLower := strings.ToLower(b.Status)
percentText := fmt.Sprintf("%5.1f%%", b.Percent)
if b.Percent < 20 && statusLower != "charging" && statusLower != "charged" {
@@ -520,7 +501,6 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
}
lines = append(lines, fmt.Sprintf("Level %s %s", batteryProgressBar(b.Percent), percentText))
// Line 2: status with power info
statusIcon := ""
statusStyle := subtleStyle
if statusLower == "charging" || statusLower == "charged" {
@@ -529,7 +509,6 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
} else if b.Percent < 20 {
statusStyle = dangerStyle
}
// Capitalize first letter
statusText := b.Status
if len(statusText) > 0 {
statusText = strings.ToUpper(statusText[:1]) + strings.ToLower(statusText[1:])
@@ -537,21 +516,18 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
if b.TimeLeft != "" {
statusText += " · " + b.TimeLeft
}
// Add power information
// Add power info.
if statusLower == "charging" || statusLower == "charged" {
// AC powered - show system power consumption
if thermal.SystemPower > 0 {
statusText += fmt.Sprintf(" · %.0fW", thermal.SystemPower)
} else if thermal.AdapterPower > 0 {
statusText += fmt.Sprintf(" · %.0fW Adapter", thermal.AdapterPower)
}
} else if thermal.BatteryPower > 0 {
// Battery powered - show discharge rate
statusText += fmt.Sprintf(" · %.0fW", thermal.BatteryPower)
}
lines = append(lines, statusStyle.Render(statusText+statusIcon))
// Line 3: Health + cycles + temp
healthParts := []string{}
if b.Health != "" {
healthParts = append(healthParts, b.Health)
@@ -560,7 +536,6 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
healthParts = append(healthParts, fmt.Sprintf("%d cycles", b.CycleCount))
}
// Add temperature if available
if thermal.CPUTemp > 0 {
tempStyle := subtleStyle
if thermal.CPUTemp > 80 {
@@ -571,7 +546,6 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
healthParts = append(healthParts, tempStyle.Render(fmt.Sprintf("%.0f°C", thermal.CPUTemp)))
}
// Add fan speed if available
if thermal.FanSpeed > 0 {
healthParts = append(healthParts, fmt.Sprintf("%d RPM", thermal.FanSpeed))
}
@@ -607,7 +581,6 @@ func renderCard(data cardData, width int, height int) string {
header := titleStyle.Render(titleText) + " " + lineStyle.Render(strings.Repeat("╌", lineLen))
content := header + "\n" + strings.Join(data.lines, "\n")
// Pad to target height
lines := strings.Split(content, "\n")
for len(lines) < height {
lines = append(lines, "")
@@ -780,7 +753,6 @@ func renderTwoColumns(cards []cardData, width int) string {
}
}
// Add empty lines between rows for separation
var spacedRows []string
for i, r := range rows {
if i > 0 {