1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-04 18:34:46 +00:00
Files
Mole/cmd/analyze/main.go

2211 lines
54 KiB
Go

//go:build darwin
package main
import (
"bytes"
"context"
"crypto/md5"
"encoding/gob"
"encoding/json"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
tea "github.com/charmbracelet/bubbletea"
)
const (
maxEntries = 20
maxLargeFiles = 20
barWidth = 24
minLargeFileSize = 100 << 20 // 100 MB
entryViewport = 10
largeViewport = 10
overviewCacheTTL = 7 * 24 * time.Hour // 7 days
overviewCacheFile = "overview_sizes.json"
duTimeout = 60 * time.Second // Increased for large directories
mdlsTimeout = 5 * time.Second
maxConcurrentOverview = 3 // Scan up to 3 overview dirs concurrently
pathUpdateInterval = 500 // Update current path every N files
batchUpdateSize = 100 // Batch atomic updates every N items
)
// Directories to fold: calculate size but don't expand children
var foldDirs = map[string]bool{
".git": true,
"node_modules": true,
".Trash": true,
".npm": true,
".cache": true,
".yarn": true,
".pnpm-store": true,
"__pycache__": true,
".pytest_cache": true,
"target": true, // Rust/Java build output
"build": true,
"dist": true,
".next": true,
".nuxt": true,
}
// System directories to skip (macOS specific)
var skipSystemDirs = map[string]bool{
"dev": true,
"tmp": true,
"private": true,
"cores": true,
"net": true,
"home": true,
"System": true, // macOS system files
"sbin": true,
"bin": true,
"etc": true,
"var": true,
".vol": true,
".Spotlight-V100": true,
".fseventsd": true,
".DocumentRevisions-V100": true,
".TemporaryItems": true,
}
// File extensions to skip for large file tracking
var skipExtensions = map[string]bool{
".go": true,
".js": true,
".ts": true,
".jsx": true,
".tsx": true,
".py": true,
".rb": true,
".java": true,
".c": true,
".cpp": true,
".h": true,
".hpp": true,
".rs": true,
".swift": true,
".m": true,
".mm": true,
".sh": true,
".txt": true,
".md": true,
".json": true,
".xml": true,
".yaml": true,
".yml": true,
".toml": true,
".css": true,
".scss": true,
".html": true,
".svg": true,
}
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"}
type overviewSizeSnapshot struct {
Size int64 `json:"size"`
Updated time.Time `json:"updated"`
}
var (
overviewSnapshotMu sync.Mutex
overviewSnapshotCache map[string]overviewSizeSnapshot
overviewSnapshotLoaded bool
overviewSnapshotPathErr error
)
const (
colorPurple = "\033[0;35m"
colorBlue = "\033[0;34m"
colorGray = "\033[0;90m"
colorRed = "\033[0;31m"
colorYellow = "\033[1;33m"
colorGreen = "\033[0;32m"
colorCyan = "\033[0;36m"
colorReset = "\033[0m"
colorBold = "\033[1m"
colorBgCyan = "\033[46m"
colorBgDark = "\033[100m"
colorInvert = "\033[7m"
)
type dirEntry struct {
name string
path string
size int64
isDir bool
lastAccess time.Time
}
type fileEntry struct {
name string
path string
size int64
}
type scanResult struct {
entries []dirEntry
largeFiles []fileEntry
totalSize int64
}
type cacheEntry struct {
Entries []dirEntry
LargeFiles []fileEntry
TotalSize int64
ModTime time.Time
ScanTime time.Time
}
type historyEntry struct {
path string
entries []dirEntry
largeFiles []fileEntry
totalSize int64
selected int
entryOffset int
largeSelected int
largeOffset int
dirty bool
}
type scanResultMsg struct {
result scanResult
err error
}
type overviewSizeMsg struct {
path string
index int
size int64
err error
}
type tickMsg time.Time
type model struct {
path string
history []historyEntry
entries []dirEntry
largeFiles []fileEntry
selected int
offset int
status string
totalSize int64
scanning bool
spinner int
filesScanned *int64
dirsScanned *int64
bytesScanned *int64
currentPath *string
showLargeFiles bool
isOverview bool
showFlameGraph bool
deleteConfirm bool
deleteTarget *dirEntry
cache map[string]historyEntry
largeSelected int
largeOffset int
overviewSizeCache map[string]int64
overviewFilesScanned *int64
overviewDirsScanned *int64
overviewBytesScanned *int64
overviewCurrentPath *string
overviewScanning bool
overviewScanningSet map[string]bool // Track which paths are currently being scanned
}
func main() {
target := os.Getenv("MO_ANALYZE_PATH")
if target == "" && len(os.Args) > 1 {
target = os.Args[1]
}
var abs string
var isOverview bool
if target == "" {
// Default to overview mode
isOverview = true
abs = "/"
} else {
var err error
abs, err = filepath.Abs(target)
if err != nil {
fmt.Fprintf(os.Stderr, "cannot resolve %q: %v\n", target, err)
os.Exit(1)
}
isOverview = false
}
p := tea.NewProgram(newModel(abs, isOverview), tea.WithAltScreen())
if err := p.Start(); err != nil {
fmt.Fprintf(os.Stderr, "analyzer error: %v\n", err)
os.Exit(1)
}
}
func newModel(path string, isOverview bool) model {
var filesScanned, dirsScanned, bytesScanned int64
currentPath := ""
var overviewFilesScanned, overviewDirsScanned, overviewBytesScanned int64
overviewCurrentPath := ""
m := model{
path: path,
selected: 0,
status: "Preparing scan...",
scanning: !isOverview,
filesScanned: &filesScanned,
dirsScanned: &dirsScanned,
bytesScanned: &bytesScanned,
currentPath: &currentPath,
showLargeFiles: false,
isOverview: isOverview,
cache: make(map[string]historyEntry),
overviewFilesScanned: &overviewFilesScanned,
overviewDirsScanned: &overviewDirsScanned,
overviewBytesScanned: &overviewBytesScanned,
overviewCurrentPath: &overviewCurrentPath,
}
// In overview mode, create shortcut entries
if isOverview {
m.scanning = false
m.overviewSizeCache = make(map[string]int64)
m.overviewScanningSet = make(map[string]bool)
m.hydrateOverviewEntries()
m.selected = 0
m.offset = 0
if nextPendingOverviewIndex(m.entries) >= 0 {
m.overviewScanning = true
m.status = "Estimating system roots..."
} else {
m.status = "Ready"
}
}
return m
}
func createOverviewEntries() []dirEntry {
home := os.Getenv("HOME")
entries := []dirEntry{}
if home != "" {
entries = append(entries,
dirEntry{name: "Home (~)", path: home, isDir: true, size: -1},
dirEntry{name: "Library (~/Library)", path: filepath.Join(home, "Library"), isDir: true, size: -1},
)
}
entries = append(entries,
dirEntry{name: "Applications", path: "/Applications", isDir: true, size: -1},
dirEntry{name: "System Library", path: "/Library", isDir: true, size: -1},
)
// Add Volumes if exists
if _, err := os.Stat("/Volumes"); err == nil {
entries = append(entries, dirEntry{name: "Volumes", path: "/Volumes", isDir: true, size: -1})
}
return entries
}
func (m *model) hydrateOverviewEntries() {
m.entries = createOverviewEntries()
if m.overviewSizeCache == nil {
m.overviewSizeCache = make(map[string]int64)
}
for i := range m.entries {
if size, ok := m.overviewSizeCache[m.entries[i].path]; ok {
m.entries[i].size = size
continue
}
if size, err := loadOverviewCachedSize(m.entries[i].path); err == nil {
m.entries[i].size = size
m.overviewSizeCache[m.entries[i].path] = size
}
}
m.totalSize = sumKnownEntrySizes(m.entries)
}
func (m *model) scheduleOverviewScans() tea.Cmd {
if !m.isOverview {
return nil
}
// Initialize scanning set if needed
if m.overviewScanningSet == nil {
m.overviewScanningSet = make(map[string]bool)
}
// 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] {
pendingIndices = append(pendingIndices, i)
if len(pendingIndices) >= maxConcurrentOverview {
break
}
}
}
// No more work to do
if len(pendingIndices) == 0 {
m.overviewScanning = false
if !hasPendingOverviewEntries(m.entries) {
m.status = "Ready"
}
return nil
}
// Mark all as scanning
var cmds []tea.Cmd
for _, idx := range pendingIndices {
entry := m.entries[idx]
m.overviewScanningSet[entry.path] = true
cmd := scanOverviewPathCmd(entry.path, idx)
cmds = append(cmds, cmd)
}
m.overviewScanning = true
remaining := 0
for _, e := range m.entries {
if e.size < 0 {
remaining++
}
}
if len(pendingIndices) > 0 {
firstEntry := m.entries[pendingIndices[0]]
if len(pendingIndices) == 1 {
m.status = fmt.Sprintf("Scanning %s... (%d left)", firstEntry.name, remaining)
} else {
m.status = fmt.Sprintf("Scanning %d directories... (%d left)", len(pendingIndices), remaining)
}
}
cmds = append(cmds, tickCmd())
return tea.Batch(cmds...)
}
func (m *model) updateScanProgress(files, dirs, bytes int64, path string) {
if m.filesScanned != nil {
atomic.StoreInt64(m.filesScanned, files)
}
if m.dirsScanned != nil {
atomic.StoreInt64(m.dirsScanned, dirs)
}
if m.bytesScanned != nil {
atomic.StoreInt64(m.bytesScanned, bytes)
}
if m.currentPath != nil && path != "" {
*m.currentPath = path
}
}
func (m *model) getScanProgress() (files, dirs, bytes int64) {
if m.filesScanned != nil {
files = atomic.LoadInt64(m.filesScanned)
}
if m.dirsScanned != nil {
dirs = atomic.LoadInt64(m.dirsScanned)
}
if m.bytesScanned != nil {
bytes = atomic.LoadInt64(m.bytesScanned)
}
return
}
func (m *model) getOverviewScanProgress() (files, dirs, bytes int64) {
if m.overviewFilesScanned != nil {
files = atomic.LoadInt64(m.overviewFilesScanned)
}
if m.overviewDirsScanned != nil {
dirs = atomic.LoadInt64(m.overviewDirsScanned)
}
if m.overviewBytesScanned != nil {
bytes = atomic.LoadInt64(m.overviewBytesScanned)
}
return
}
func (m model) Init() tea.Cmd {
if m.isOverview {
return m.scheduleOverviewScans()
}
return tea.Batch(m.scanCmd(m.path), tickCmd())
}
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,
largeFiles: cached.LargeFiles,
totalSize: cached.TotalSize,
}
return scanResultMsg{result: result, err: nil}
}
// Cache miss or invalid, perform actual scan
result, err := scanPathConcurrent(path, m.filesScanned, m.dirsScanned, m.bytesScanned, m.currentPath)
// Save to persistent cache asynchronously
if err == nil {
go saveCacheToDisk(path, result)
}
return scanResultMsg{result: result, err: err}
}
}
func tickCmd() tea.Cmd {
return tea.Tick(time.Millisecond*120, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
return m.updateKey(msg)
case scanResultMsg:
m.scanning = false
if msg.err != nil {
m.status = fmt.Sprintf("Scan failed: %v", msg.err)
return m, nil
}
m.entries = msg.result.entries
m.largeFiles = msg.result.largeFiles
m.totalSize = msg.result.totalSize
m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize))
m.clampEntrySelection()
m.clampLargeSelection()
m.cache[m.path] = cacheSnapshot(m)
if m.totalSize > 0 {
if m.overviewSizeCache == nil {
m.overviewSizeCache = make(map[string]int64)
}
m.overviewSizeCache[m.path] = m.totalSize
go func(path string, size int64) {
_ = storeOverviewSize(path, size)
}(m.path, m.totalSize)
}
return m, nil
case overviewSizeMsg:
if m.overviewSizeCache == nil {
m.overviewSizeCache = make(map[string]int64)
}
if m.overviewScanningSet == nil {
m.overviewScanningSet = make(map[string]bool)
}
// Remove from scanning set
delete(m.overviewScanningSet, msg.path)
if msg.err == nil {
m.overviewSizeCache[msg.path] = msg.size
}
if m.isOverview {
// Update entry with result
for i := range m.entries {
if m.entries[i].path == msg.path {
if msg.err == nil {
m.entries[i].size = msg.size
} else {
m.entries[i].size = 0
}
break
}
}
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:
if m.scanning || (m.isOverview && m.overviewScanning) {
m.spinner = (m.spinner + 1) % len(spinnerFrames)
return m, tickCmd()
}
return m, nil
default:
return m, nil
}
}
func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Handle delete confirmation
if m.deleteConfirm {
if msg.String() == "delete" || msg.String() == "backspace" {
// Confirm delete
if m.deleteTarget != nil {
err := os.RemoveAll(m.deleteTarget.path)
if err != nil {
m.status = fmt.Sprintf("Failed to delete: %v", err)
} else {
m.status = fmt.Sprintf("Deleted %s", m.deleteTarget.name)
for i := range m.history {
m.history[i].dirty = true
}
for path := range m.cache {
entry := m.cache[path]
entry.dirty = true
m.cache[path] = entry
}
// Refresh the view
m.scanning = true
m.deleteConfirm = false
m.deleteTarget = nil
return m, tea.Batch(m.scanCmd(m.path), tickCmd())
}
}
m.deleteConfirm = false
m.deleteTarget = nil
return m, nil
} else if msg.String() == "esc" || msg.String() == "q" {
// Cancel delete with ESC or Q
m.status = "Cancelled"
m.deleteConfirm = false
m.deleteTarget = nil
return m, nil
} else {
// Any other key also cancels
m.status = "Cancelled"
m.deleteConfirm = false
m.deleteTarget = nil
return m, nil
}
}
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "esc":
if m.showLargeFiles {
m.showLargeFiles = false
return m, nil
}
if m.showFlameGraph {
m.showFlameGraph = false
return m, nil
}
return m, tea.Quit
case "up", "k":
if m.showLargeFiles {
if m.largeSelected > 0 {
m.largeSelected--
if m.largeSelected < m.largeOffset {
m.largeOffset = m.largeSelected
}
}
} else if len(m.entries) > 0 && m.selected > 0 {
m.selected--
if m.selected < m.offset {
m.offset = m.selected
}
}
case "down", "j":
if m.showLargeFiles {
if m.largeSelected < len(m.largeFiles)-1 {
m.largeSelected++
if m.largeSelected >= m.largeOffset+largeViewport {
m.largeOffset = m.largeSelected - largeViewport + 1
}
}
} else if len(m.entries) > 0 && m.selected < len(m.entries)-1 {
m.selected++
if m.selected >= m.offset+entryViewport {
m.offset = m.selected - entryViewport + 1
}
}
case "enter":
if m.showLargeFiles {
return m, nil
}
return m.enterSelectedDir()
case "right":
if m.showLargeFiles {
return m, nil
}
return m.enterSelectedDir()
case "b", "left":
if m.showLargeFiles {
m.showLargeFiles = false
return m, nil
}
if m.showFlameGraph {
m.showFlameGraph = false
return m, nil
}
if len(m.history) == 0 {
// Return to overview if at top level
if !m.isOverview {
return m, m.switchToOverviewMode()
}
return m, nil
}
last := m.history[len(m.history)-1]
m.history = m.history[:len(m.history)-1]
m.path = last.path
m.selected = last.selected
m.offset = last.entryOffset
m.largeSelected = last.largeSelected
m.largeOffset = last.largeOffset
m.isOverview = false
if last.dirty {
m.status = "Scanning..."
m.scanning = true
return m, tea.Batch(m.scanCmd(m.path), tickCmd())
}
m.entries = last.entries
m.largeFiles = last.largeFiles
m.totalSize = last.totalSize
m.clampEntrySelection()
m.clampLargeSelection()
if len(m.entries) == 0 {
m.selected = 0
} else if m.selected >= len(m.entries) {
m.selected = len(m.entries) - 1
}
if m.selected < 0 {
m.selected = 0
}
m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize))
m.scanning = false
return m, nil
case "r":
m.status = "Refreshing..."
m.scanning = true
return m, tea.Batch(m.scanCmd(m.path), tickCmd())
case "l":
m.showLargeFiles = !m.showLargeFiles
m.showFlameGraph = false
if m.showLargeFiles {
m.largeSelected = 0
m.largeOffset = 0
}
case "g", "v":
// Toggle flame graph view
if m.isOverview {
return m, nil // Flame graph not available in overview mode
}
m.showFlameGraph = !m.showFlameGraph
m.showLargeFiles = false
case "o":
// Open selected entry
if m.showLargeFiles {
if len(m.largeFiles) > 0 {
selected := m.largeFiles[m.largeSelected]
go func() {
_ = exec.Command("open", selected.path).Run()
}()
m.status = fmt.Sprintf("Opening %s...", selected.name)
}
} else if len(m.entries) > 0 {
selected := m.entries[m.selected]
go func() {
_ = exec.Command("open", selected.path).Run()
}()
m.status = fmt.Sprintf("Opening %s...", selected.name)
}
case "f", "F":
// Reveal selected entry in Finder
if m.showLargeFiles {
if len(m.largeFiles) > 0 {
selected := m.largeFiles[m.largeSelected]
go func(path string) {
_ = exec.Command("open", "-R", path).Run()
}(selected.path)
m.status = fmt.Sprintf("Revealing %s in Finder...", selected.name)
}
} else if len(m.entries) > 0 {
selected := m.entries[m.selected]
go func(path string) {
_ = exec.Command("open", "-R", path).Run()
}(selected.path)
m.status = fmt.Sprintf("Revealing %s in Finder...", selected.name)
}
case "delete", "backspace":
// Delete selected file or directory
if m.showLargeFiles {
if len(m.largeFiles) > 0 {
selected := m.largeFiles[m.largeSelected]
m.deleteConfirm = true
m.deleteTarget = &dirEntry{
name: selected.name,
path: selected.path,
size: selected.size,
isDir: false,
}
}
} else if len(m.entries) > 0 && !m.isOverview {
selected := m.entries[m.selected]
m.deleteConfirm = true
m.deleteTarget = &selected
}
}
return m, nil
}
func (m *model) switchToOverviewMode() tea.Cmd {
if m.overviewSizeCache == nil {
m.overviewSizeCache = make(map[string]int64)
}
if m.overviewScanningSet == nil {
m.overviewScanningSet = make(map[string]bool)
}
m.isOverview = true
m.path = "/"
m.scanning = false
m.showLargeFiles = false
m.largeFiles = nil
m.largeSelected = 0
m.largeOffset = 0
m.deleteConfirm = false
m.deleteTarget = nil
m.selected = 0
m.offset = 0
m.hydrateOverviewEntries()
cmd := m.scheduleOverviewScans()
if cmd == nil {
m.status = "Ready"
}
return cmd
}
func (m model) enterSelectedDir() (tea.Model, tea.Cmd) {
if len(m.entries) == 0 {
return m, nil
}
selected := m.entries[m.selected]
if selected.isDir {
if !m.isOverview {
m.history = append(m.history, snapshotFromModel(m))
}
m.path = selected.path
m.selected = 0
m.offset = 0
m.status = "Scanning..."
m.scanning = true
m.isOverview = false
if cached, ok := m.cache[m.path]; ok && !cached.dirty {
m.entries = cloneDirEntries(cached.entries)
m.largeFiles = cloneFileEntries(cached.largeFiles)
m.totalSize = cached.totalSize
m.selected = cached.selected
m.offset = cached.entryOffset
m.largeSelected = cached.largeSelected
m.largeOffset = cached.largeOffset
m.clampEntrySelection()
m.clampLargeSelection()
m.status = fmt.Sprintf("Cached view for %s", displayPath(m.path))
m.scanning = false
return m, nil
}
return m, tea.Batch(m.scanCmd(m.path), tickCmd())
}
m.status = fmt.Sprintf("File: %s (%s)", selected.name, humanizeBytes(selected.size))
return m, nil
}
func (m model) View() string {
var b strings.Builder
fmt.Fprintln(&b)
// Visualization removed - will be redesigned
_ = m.showFlameGraph
if m.isOverview && hasPendingOverviewEntries(m.entries) {
fmt.Fprintf(&b, "%sAnalyze Disk%s\n", colorPurple, colorReset)
fmt.Fprintf(&b, "%sPreparing overview...%s\n\n", colorGray, colorReset)
filesScanned, dirsScanned, bytesScanned := m.getOverviewScanProgress()
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.overviewCurrentPath != nil {
currentPath := *m.overviewCurrentPath
if currentPath != "" {
shortPath := displayPath(currentPath)
if len(shortPath) > 60 {
shortPath = "..." + shortPath[len(shortPath)-57:]
}
fmt.Fprintf(&b, "%s%s%s\n", colorGray, shortPath, colorReset)
}
}
return b.String()
}
if m.deleteConfirm && m.deleteTarget != nil {
// Show delete confirmation prominently at the top
fmt.Fprintf(&b, "%sDelete: %s (%s)? Press Delete again to confirm, ESC to cancel%s\n\n",
colorRed, m.deleteTarget.name, humanizeBytes(m.deleteTarget.size), colorReset)
}
if m.isOverview {
fmt.Fprintf(&b, "%sAnalyze Disk%s\n", colorPurple, colorReset)
fmt.Fprintf(&b, "%sSelect a location to explore:%s\n", colorGray, colorReset)
} else {
fmt.Fprintf(&b, "%sAnalyze Disk%s %s%s%s", colorPurple, colorReset, colorGray, displayPath(m.path), colorReset)
if !m.scanning {
fmt.Fprintf(&b, " | Total: %s", humanizeBytes(m.totalSize))
}
fmt.Fprintln(&b)
}
if m.scanning {
filesScanned, dirsScanned, bytesScanned := m.getScanProgress()
fmt.Fprintf(&b, "\n%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)
if len(shortPath) > 60 {
shortPath = "..." + shortPath[len(shortPath)-57:]
}
fmt.Fprintf(&b, "%s%s%s\n", colorGray, shortPath, colorReset)
}
}
return b.String()
}
fmt.Fprintln(&b)
if m.showLargeFiles {
if len(m.largeFiles) == 0 {
fmt.Fprintln(&b, " No large files found (>=100MB)")
} else {
start := m.largeOffset
if start < 0 {
start = 0
}
end := start + largeViewport
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)
if len(shortPath) > 56 {
shortPath = shortPath[:53] + "..."
}
entryPrefix := " "
if idx == m.largeSelected {
entryPrefix = fmt.Sprintf(" %s%s▶%s ", colorCyan, colorBold, colorReset)
}
nameColumn := padName(shortPath, 56)
size := humanizeBytes(file.size)
bar := coloredProgressBar(file.size, maxLargeSize, 0)
fmt.Fprintf(&b, "%s%2d. %s | 📄 %s %s%10s%s\n",
entryPrefix, idx+1, bar, nameColumn, colorGray, size, colorReset)
}
}
} else {
if len(m.entries) == 0 {
fmt.Fprintln(&b, " Empty directory")
} else {
if m.isOverview {
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 = colorCyan
default:
sizeColor = colorGray
}
}
entryPrefix := " "
name := trimName(entry.name)
paddedName := padName(name, 28)
nameSegment := fmt.Sprintf("%s %s", icon, paddedName)
if idx == m.selected {
entryPrefix = fmt.Sprintf(" %s%s▶%s ", colorCyan, colorBold, colorReset)
nameSegment = fmt.Sprintf("%s%s %s%s", colorBold, icon, paddedName, colorReset)
}
displayIndex := idx + 1
// Add unused time label if applicable
// For overview mode, get access time on-demand if not set and cache it
lastAccess := entry.lastAccess
if lastAccess.IsZero() && entry.path != "" {
lastAccess = getLastAccessTime(entry.path)
// Cache the result to avoid repeated syscalls
m.entries[idx].lastAccess = lastAccess
}
unusedLabel := formatUnusedTime(lastAccess)
if unusedLabel == "" {
fmt.Fprintf(&b, "%s%2d. %s %s | %s %s%10s%s\n",
entryPrefix, displayIndex, bar, percentStr,
nameSegment, sizeColor, sizeText, colorReset)
} else {
fmt.Fprintf(&b, "%s%2d. %s %s | %s %s%10s%s %s%s%s\n",
entryPrefix, displayIndex, bar, percentStr,
nameSegment, sizeColor, sizeText, colorReset,
colorGray, unusedLabel, colorReset)
}
}
} else {
// Normal mode with sizes and progress bars
maxSize := int64(1)
for _, entry := range m.entries {
if entry.size > maxSize {
maxSize = entry.size
}
}
start := m.offset
if start < 0 {
start = 0
}
end := start + entryViewport
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 = colorCyan
} else {
sizeColor = colorGray
}
// Keep chart columns aligned even when arrow is shown
entryPrefix := " "
nameSegment := fmt.Sprintf("%s %s", icon, paddedName)
if idx == m.selected {
entryPrefix = fmt.Sprintf(" %s%s▶%s ", colorCyan, colorBold, colorReset)
nameSegment = fmt.Sprintf("%s%s %s%s", colorBold, icon, paddedName, colorReset)
}
displayIndex := idx + 1
// Add unused time label if applicable
unusedLabel := formatUnusedTime(entry.lastAccess)
if unusedLabel == "" {
fmt.Fprintf(&b, "%s%2d. %s %s | %s %s%10s%s\n",
entryPrefix, displayIndex, bar, percentStr,
nameSegment, sizeColor, size, colorReset)
} else {
fmt.Fprintf(&b, "%s%2d. %s %s | %s %s%10s%s %s%s%s\n",
entryPrefix, displayIndex, bar, percentStr,
nameSegment, sizeColor, size, colorReset,
colorGray, unusedLabel, colorReset)
}
}
}
}
}
fmt.Fprintln(&b)
if m.isOverview {
fmt.Fprintf(&b, "%s ↑↓←→ Navigate | Enter Explore | O Open | F Reveal | Q Quit%s\n", colorGray, colorReset)
} else if m.showLargeFiles {
fmt.Fprintf(&b, "%s ↑↓ Navigate | O Open | F Reveal | ⌫ Delete | Q Quit%s\n", colorGray, colorReset)
} else {
largeFileCount := len(m.largeFiles)
if largeFileCount > 0 {
fmt.Fprintf(&b, "%s ↑↓←→ Navigate | O Open | F Reveal | ⌫ Delete | G FlameGraph | L Large(%d) | Q Quit%s\n", colorGray, largeFileCount, colorReset)
} else {
fmt.Fprintf(&b, "%s ↑↓←→ Navigate | O Open | F Reveal | ⌫ Delete | G FlameGraph | Q Quit%s\n", colorGray, colorReset)
}
}
return b.String()
}
func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *string) (scanResult, error) {
children, err := os.ReadDir(root)
if err != nil {
return scanResult{}, err
}
tracker := newLargeFileTracker()
var total int64
entries := make([]dirEntry, 0, len(children))
var entriesMu sync.Mutex
// Use worker pool for concurrent directory scanning
// For I/O-bound operations, use more workers than CPU count
maxWorkers := runtime.NumCPU() * 4
if maxWorkers < 16 {
maxWorkers = 16 // Minimum 16 workers for better I/O throughput
}
// Cap at 128 to avoid excessive goroutines
if maxWorkers > 128 {
maxWorkers = 128
}
if maxWorkers > len(children) {
maxWorkers = len(children)
}
if maxWorkers < 1 {
maxWorkers = 1
}
sem := make(chan struct{}, maxWorkers)
var wg sync.WaitGroup
isRootDir := root == "/"
for _, child := range children {
fullPath := filepath.Join(root, child.Name())
if child.IsDir() {
// In root directory, skip system directories completely
if isRootDir && skipSystemDirs[child.Name()] {
continue
}
// For folded directories, calculate size quickly without expanding
if shouldFoldDir(child.Name()) {
wg.Add(1)
go func(name, path string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
size := calculateDirSizeFast(path, filesScanned, dirsScanned, bytesScanned, currentPath)
atomic.AddInt64(&total, size)
atomic.AddInt64(dirsScanned, 1)
entry := dirEntry{
name: name,
path: path,
size: size,
isDir: true,
lastAccess: getLastAccessTime(path),
}
entriesMu.Lock()
entries = append(entries, entry)
entriesMu.Unlock()
}(child.Name(), fullPath)
continue
}
// Normal directory: full scan with detail
wg.Add(1)
go func(name, path string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
size := calculateDirSizeConcurrent(path, tracker, filesScanned, dirsScanned, bytesScanned, currentPath)
atomic.AddInt64(&total, size)
atomic.AddInt64(dirsScanned, 1)
entry := dirEntry{
name: name,
path: path,
size: size,
isDir: true,
lastAccess: getLastAccessTime(path),
}
entriesMu.Lock()
entries = append(entries, entry)
entriesMu.Unlock()
}(child.Name(), fullPath)
continue
}
info, err := child.Info()
if err != nil {
continue
}
size := info.Size()
atomic.AddInt64(&total, size)
atomic.AddInt64(filesScanned, 1)
atomic.AddInt64(bytesScanned, size)
entries = append(entries, dirEntry{
name: child.Name(),
path: fullPath,
size: size,
isDir: false,
lastAccess: getLastAccessTime(fullPath),
})
// Only track large files that are not code/text files
if !shouldSkipFileForLargeTracking(fullPath) {
tracker.add(fileEntry{name: child.Name(), path: fullPath, size: size})
}
}
wg.Wait()
sort.Slice(entries, func(i, j int) bool {
return entries[i].size > entries[j].size
})
if len(entries) > maxEntries {
entries = entries[:maxEntries]
}
// Try to use Spotlight for faster large file discovery
var largeFiles []fileEntry
if spotlightFiles := findLargeFilesWithSpotlight(root, minLargeFileSize); len(spotlightFiles) > 0 {
largeFiles = spotlightFiles
} else {
// Fallback to manual tracking
largeFiles = tracker.list()
}
return scanResult{
entries: entries,
largeFiles: largeFiles,
totalSize: total,
}, nil
}
func shouldFoldDir(name string) bool {
return foldDirs[name]
}
func shouldSkipFileForLargeTracking(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
return skipExtensions[ext]
}
// calculateDirSizeFast performs fast directory size calculation without detailed tracking or large file detection.
// Updates progress counters in batches to reduce atomic operation overhead.
func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *string) int64 {
var total int64
var localFiles, localDirs int64
var batchBytes int64
_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
localDirs++
// Batch update every N dirs to reduce atomic operations
if localDirs%batchUpdateSize == 0 {
atomic.AddInt64(dirsScanned, batchUpdateSize)
localDirs = 0
}
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
size := info.Size()
total += size
batchBytes += size
localFiles++
if currentPath != nil {
*currentPath = path
}
// Batch update every N files to reduce atomic operations
if localFiles%batchUpdateSize == 0 {
atomic.AddInt64(filesScanned, batchUpdateSize)
atomic.AddInt64(bytesScanned, batchBytes)
localFiles = 0
batchBytes = 0
}
return nil
})
// Final update for remaining counts
if localFiles > 0 {
atomic.AddInt64(filesScanned, localFiles)
}
if localDirs > 0 {
atomic.AddInt64(dirsScanned, localDirs)
}
if batchBytes > 0 {
atomic.AddInt64(bytesScanned, batchBytes)
}
return total
}
// Use Spotlight (mdfind) to quickly find large files in a directory
func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
// mdfind query: files >= minSize in the specified directory
query := fmt.Sprintf("kMDItemFSSize >= %d", minSize)
cmd := exec.Command("mdfind", "-onlyin", root, query)
output, err := cmd.Output()
if err != nil {
// Fallback: mdfind not available or failed
return nil
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
var files []fileEntry
for _, line := range lines {
if line == "" {
continue
}
// Check if it's a directory, skip it
info, err := os.Stat(line)
if err != nil || info.IsDir() {
continue
}
// Filter out files in folded directories
inFoldedDir := false
for foldDir := range foldDirs {
if strings.Contains(line, string(os.PathSeparator)+foldDir+string(os.PathSeparator)) ||
strings.HasSuffix(filepath.Dir(line), string(os.PathSeparator)+foldDir) {
inFoldedDir = true
break
}
}
if inFoldedDir {
continue
}
// Filter out code files
if shouldSkipFileForLargeTracking(line) {
continue
}
files = append(files, fileEntry{
name: filepath.Base(line),
path: line,
size: info.Size(),
})
}
// 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]
}
return files
}
func calculateDirSizeConcurrent(root string, tracker *largeFileTracker, filesScanned, dirsScanned, bytesScanned *int64, currentPath *string) int64 {
var total int64
var updateCounter int64
var localFiles, localDirs int64
var batchBytes int64
_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
// Skip folded directories during recursive scanning
if shouldFoldDir(d.Name()) {
return filepath.SkipDir
}
localDirs++
// Batch update every N dirs to reduce atomic operations
if localDirs%batchUpdateSize == 0 {
atomic.AddInt64(dirsScanned, batchUpdateSize)
localDirs = 0
}
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
size := info.Size()
total += size
batchBytes += size
localFiles++
// Batch update every N files to reduce atomic operations
if localFiles%batchUpdateSize == 0 {
atomic.AddInt64(filesScanned, batchUpdateSize)
atomic.AddInt64(bytesScanned, batchBytes)
localFiles = 0
batchBytes = 0
}
// Only track large files that are not code/text files
if !shouldSkipFileForLargeTracking(path) {
tracker.add(fileEntry{name: filepath.Base(path), path: path, size: size})
}
// Update current path periodically to reduce contention
updateCounter++
if updateCounter%pathUpdateInterval == 0 && currentPath != nil {
*currentPath = path
}
return nil
})
// Final update for remaining counts
if localFiles > 0 {
atomic.AddInt64(filesScanned, localFiles)
}
if localDirs > 0 {
atomic.AddInt64(dirsScanned, localDirs)
}
if batchBytes > 0 {
atomic.AddInt64(bytesScanned, batchBytes)
}
return total
}
type largeFileTracker struct {
mu sync.Mutex
entries []fileEntry
minSize int64
needsSort bool
}
func newLargeFileTracker() *largeFileTracker {
return &largeFileTracker{
entries: make([]fileEntry, 0, maxLargeFiles*2), // Pre-allocate more space
minSize: minLargeFileSize,
}
}
func (t *largeFileTracker) add(f fileEntry) {
if f.size < t.minSize {
return
}
t.mu.Lock()
defer t.mu.Unlock()
// Just append without sorting - sort only once at the end
t.entries = append(t.entries, f)
t.needsSort = true
// Update minimum size threshold dynamically
if len(t.entries) > maxLargeFiles*3 {
// Periodically sort and trim to avoid memory bloat
sort.Slice(t.entries, func(i, j int) bool {
return t.entries[i].size > t.entries[j].size
})
if len(t.entries) > maxLargeFiles {
t.minSize = t.entries[maxLargeFiles-1].size
t.entries = t.entries[:maxLargeFiles]
}
t.needsSort = false
}
}
func (t *largeFileTracker) list() []fileEntry {
t.mu.Lock()
defer t.mu.Unlock()
// Sort only when needed
if t.needsSort {
sort.Slice(t.entries, func(i, j int) bool {
return t.entries[i].size > t.entries[j].size
})
if len(t.entries) > maxLargeFiles {
t.entries = t.entries[:maxLargeFiles]
}
t.needsSort = false
}
return append([]fileEntry(nil), t.entries...)
}
func displayPath(path string) string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return path
}
if strings.HasPrefix(path, home) {
return strings.Replace(path, home, "~", 1)
}
return path
}
func formatNumber(n int64) string {
if n < 1000 {
return fmt.Sprintf("%d", n)
}
if n < 1000000 {
return fmt.Sprintf("%.1fk", float64(n)/1000)
}
return fmt.Sprintf("%.1fM", float64(n)/1000000)
}
func humanizeBytes(size int64) string {
if size < 0 {
return "0 B"
}
const unit = 1024
if size < unit {
return fmt.Sprintf("%d B", size)
}
div, exp := int64(unit), 0
for n := size / unit; n >= unit; n /= unit {
div *= unit
exp++
}
value := float64(size) / float64(div)
return fmt.Sprintf("%.1f %cB", value, "KMGTPE"[exp])
}
func progressBar(value, max int64) string {
if max <= 0 {
return strings.Repeat("░", barWidth)
}
filled := int((value * int64(barWidth)) / max)
if filled > barWidth {
filled = barWidth
}
bar := strings.Repeat("█", filled)
if filled < barWidth {
bar += strings.Repeat("░", barWidth-filled)
}
return bar
}
func coloredProgressBar(value, max int64, percent float64) string {
if max <= 0 {
return colorGray + strings.Repeat("░", barWidth) + colorReset
}
filled := int((value * int64(barWidth)) / max)
if filled > barWidth {
filled = barWidth
}
// Choose color based on percentage
var barColor string
if percent >= 50 {
barColor = colorRed // Large files in red
} else if percent >= 20 {
barColor = colorYellow // Medium files in yellow
} else if percent >= 5 {
barColor = colorCyan // Small-medium in cyan
} else {
barColor = colorGreen // Small files in green
}
// Create gradient bar with different characters
bar := barColor
for i := 0; i < barWidth; i++ {
if i < filled {
if i < filled-1 {
bar += "█"
} else {
// Last filled character might be partial
remainder := (value * int64(barWidth)) % max
if remainder > max/2 {
bar += "█"
} else if remainder > max/4 {
bar += "▓"
} else {
bar += "▒"
}
}
} else {
bar += colorGray + "░" + barColor
}
}
bar += colorReset
return bar
}
// Calculate display width considering CJK characters
func runeWidth(r rune) int {
if r >= 0x4E00 && r <= 0x9FFF || // CJK Unified Ideographs
r >= 0x3400 && r <= 0x4DBF || // CJK Extension A
r >= 0xAC00 && r <= 0xD7AF || // Hangul
r >= 0xFF00 && r <= 0xFFEF { // Fullwidth forms
return 2
}
return 1
}
func displayWidth(s string) int {
width := 0
for _, r := range s {
width += runeWidth(r)
}
return width
}
func trimName(name string) string {
const (
maxWidth = 28
ellipsis = "..."
ellipsisWidth = 3
)
runes := []rune(name)
widths := make([]int, len(runes))
for i, r := range runes {
widths[i] = runeWidth(r)
}
currentWidth := 0
for i, w := range widths {
if currentWidth+w > maxWidth {
subWidth := currentWidth
j := i
for j > 0 && subWidth+ellipsisWidth > maxWidth {
j--
subWidth -= widths[j]
}
if j == 0 {
return ellipsis
}
return string(runes[:j]) + ellipsis
}
currentWidth += w
}
return name
}
func padName(name string, targetWidth int) string {
currentWidth := displayWidth(name)
if currentWidth >= targetWidth {
return name
}
return name + strings.Repeat(" ", targetWidth-currentWidth)
}
func (m *model) clampEntrySelection() {
if len(m.entries) == 0 {
m.selected = 0
m.offset = 0
return
}
if m.selected >= len(m.entries) {
m.selected = len(m.entries) - 1
}
if m.selected < 0 {
m.selected = 0
}
maxOffset := len(m.entries) - entryViewport
if maxOffset < 0 {
maxOffset = 0
}
if m.offset > maxOffset {
m.offset = maxOffset
}
if m.selected < m.offset {
m.offset = m.selected
}
if m.selected >= m.offset+entryViewport {
m.offset = m.selected - entryViewport + 1
}
}
func (m *model) clampLargeSelection() {
if len(m.largeFiles) == 0 {
m.largeSelected = 0
m.largeOffset = 0
return
}
if m.largeSelected >= len(m.largeFiles) {
m.largeSelected = len(m.largeFiles) - 1
}
if m.largeSelected < 0 {
m.largeSelected = 0
}
maxOffset := len(m.largeFiles) - largeViewport
if maxOffset < 0 {
maxOffset = 0
}
if m.largeOffset > maxOffset {
m.largeOffset = maxOffset
}
if m.largeSelected < m.largeOffset {
m.largeOffset = m.largeSelected
}
if m.largeSelected >= m.largeOffset+largeViewport {
m.largeOffset = m.largeSelected - largeViewport + 1
}
}
func cloneDirEntries(entries []dirEntry) []dirEntry {
if len(entries) == 0 {
return nil
}
copied := make([]dirEntry, len(entries))
copy(copied, entries)
return copied
}
func cloneFileEntries(files []fileEntry) []fileEntry {
if len(files) == 0 {
return nil
}
copied := make([]fileEntry, len(files))
copy(copied, files)
return copied
}
func sumKnownEntrySizes(entries []dirEntry) int64 {
var total int64
for _, entry := range entries {
if entry.size > 0 {
total += entry.size
}
}
return total
}
func nextPendingOverviewIndex(entries []dirEntry) int {
for i, entry := range entries {
if entry.size < 0 {
return i
}
}
return -1
}
func hasPendingOverviewEntries(entries []dirEntry) bool {
for _, entry := range entries {
if entry.size < 0 {
return true
}
}
return false
}
func ensureOverviewSnapshotCacheLocked() error {
if overviewSnapshotLoaded {
return nil
}
storePath, err := getOverviewSizeStorePath()
if err != nil {
return err
}
data, err := os.ReadFile(storePath)
if err != nil {
if os.IsNotExist(err) {
overviewSnapshotCache = make(map[string]overviewSizeSnapshot)
overviewSnapshotLoaded = true
return nil
}
return err
}
if len(data) == 0 {
overviewSnapshotCache = make(map[string]overviewSizeSnapshot)
overviewSnapshotLoaded = true
return nil
}
var snapshots map[string]overviewSizeSnapshot
if err := json.Unmarshal(data, &snapshots); err != nil || snapshots == nil {
// File is corrupted, rename it instead of silently discarding
backupPath := storePath + ".corrupt"
_ = os.Rename(storePath, backupPath)
overviewSnapshotCache = make(map[string]overviewSizeSnapshot)
overviewSnapshotLoaded = true
return nil
}
overviewSnapshotCache = snapshots
overviewSnapshotLoaded = true
return nil
}
func getOverviewSizeStorePath() (string, error) {
cacheDir, err := getCacheDir()
if err != nil {
return "", err
}
return filepath.Join(cacheDir, "overview_sizes.json"), nil
}
// loadStoredOverviewSize retrieves cached directory size from JSON cache.
// Returns error if cache is missing or expired (older than overviewCacheTTL).
func loadStoredOverviewSize(path string) (int64, error) {
if path == "" {
return 0, fmt.Errorf("empty path")
}
overviewSnapshotMu.Lock()
defer overviewSnapshotMu.Unlock()
if err := ensureOverviewSnapshotCacheLocked(); err != nil {
return 0, err
}
if overviewSnapshotCache == nil {
return 0, fmt.Errorf("snapshot cache unavailable")
}
if snapshot, ok := overviewSnapshotCache[path]; ok && snapshot.Size > 0 {
// Check if cache is still valid
if time.Since(snapshot.Updated) < overviewCacheTTL {
return snapshot.Size, nil
}
return 0, fmt.Errorf("snapshot expired")
}
return 0, fmt.Errorf("snapshot not found")
}
// storeOverviewSize saves directory size to JSON cache with current timestamp.
func storeOverviewSize(path string, size int64) error {
if path == "" || size <= 0 {
return fmt.Errorf("invalid overview size")
}
overviewSnapshotMu.Lock()
defer overviewSnapshotMu.Unlock()
if err := ensureOverviewSnapshotCacheLocked(); err != nil {
return err
}
if overviewSnapshotCache == nil {
overviewSnapshotCache = make(map[string]overviewSizeSnapshot)
}
overviewSnapshotCache[path] = overviewSizeSnapshot{
Size: size,
Updated: time.Now(),
}
return persistOverviewSnapshotLocked()
}
func persistOverviewSnapshotLocked() error {
storePath, err := getOverviewSizeStorePath()
if err != nil {
return err
}
tmpPath := storePath + ".tmp"
data, err := json.MarshalIndent(overviewSnapshotCache, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
return err
}
return os.Rename(tmpPath, storePath)
}
func loadOverviewCachedSize(path string) (int64, error) {
if path == "" {
return 0, fmt.Errorf("empty path")
}
if snapshot, err := loadStoredOverviewSize(path); err == nil {
return snapshot, nil
}
cacheEntry, err := loadCacheFromDisk(path)
if err != nil {
return 0, err
}
_ = storeOverviewSize(path, cacheEntry.TotalSize)
return cacheEntry.TotalSize, nil
}
func scanOverviewPathCmd(path string, index int) tea.Cmd {
return func() tea.Msg {
size, err := measureOverviewSize(path)
return overviewSizeMsg{
path: path,
index: index,
size: size,
err: err,
}
}
}
// measureOverviewSize calculates the size of a directory using multiple strategies:
// 1. Check JSON cache (fast)
// 2. Try mdls metadata (fast, macOS only)
// 3. Walk the directory to get logical size (matches detailed scans)
// 4. Try du command (moderate speed, physical size)
// 5. Check gob cache (fallback)
func measureOverviewSize(path string) (int64, error) {
if path == "" {
return 0, fmt.Errorf("empty path")
}
// Clean and validate path
path = filepath.Clean(path)
if !filepath.IsAbs(path) {
return 0, fmt.Errorf("path must be absolute: %s", path)
}
if _, err := os.Stat(path); err != nil {
return 0, fmt.Errorf("cannot access path: %v", err)
}
// Strategy 1: Check JSON cache
if cached, err := loadStoredOverviewSize(path); err == nil && cached > 0 {
return cached, nil
}
// Strategy 2: Try mdls (fastest, macOS only)
if metadataSize, err := getDirectorySizeFromMetadata(path); err == nil && metadataSize > 0 {
_ = storeOverviewSize(path, metadataSize)
return metadataSize, nil
}
// Strategy 3: Fall back to a quick logical size walk so the result matches detailed scans.
if logicalSize, err := getDirectoryLogicalSize(path); err == nil && logicalSize > 0 {
_ = storeOverviewSize(path, logicalSize)
return logicalSize, nil
}
// Strategy 4: Try du command (fast and reliable for physical size)
if duSize, err := getDirectorySizeFromDu(path); err == nil && duSize > 0 {
_ = storeOverviewSize(path, duSize)
return duSize, nil
}
// Strategy 5: Check gob cache as fallback
if cached, err := loadCacheFromDisk(path); err == nil {
_ = storeOverviewSize(path, cached.TotalSize)
return cached.TotalSize, nil
}
// If every shortcut fails, bubble the error so caller can display a warning.
return 0, fmt.Errorf("unable to measure directory size with fast methods")
}
// getDirectorySizeFromMetadata attempts to retrieve directory size using macOS Spotlight metadata.
// This is much faster than filesystem traversal but may not be available for all directories.
func getDirectorySizeFromMetadata(path string) (int64, error) {
// mdls only works on directories
info, err := os.Stat(path)
if err != nil {
return 0, fmt.Errorf("cannot stat path: %v", err)
}
if !info.IsDir() {
return 0, fmt.Errorf("not a directory")
}
ctx, cancel := context.WithTimeout(context.Background(), mdlsTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "mdls", "-raw", "-name", "kMDItemFSSize", path)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return 0, fmt.Errorf("mdls timeout after %v", mdlsTimeout)
}
if stderr.Len() > 0 {
return 0, fmt.Errorf("mdls failed: %v (%s)", err, stderr.String())
}
return 0, fmt.Errorf("mdls failed: %v", err)
}
value := strings.TrimSpace(stdout.String())
if value == "" || value == "(null)" {
return 0, fmt.Errorf("metadata size unavailable")
}
size, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse mdls output: %v", err)
}
if size <= 0 {
return 0, fmt.Errorf("mdls size invalid: %d", size)
}
return size, nil
}
// getDirectorySizeFromDu calculates directory size using the du command.
// Uses -d 0 to avoid recursing into subdirectories, and includes a timeout protection.
func getDirectorySizeFromDu(path string) (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), duTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "du", "-sk", "-d", "0", path)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return 0, fmt.Errorf("du timeout after %v", duTimeout)
}
if stderr.Len() > 0 {
return 0, fmt.Errorf("du failed: %v (%s)", err, stderr.String())
}
return 0, fmt.Errorf("du failed: %v", err)
}
fields := strings.Fields(stdout.String())
if len(fields) == 0 {
return 0, fmt.Errorf("du output empty")
}
kb, err := strconv.ParseInt(fields[0], 10, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse du output: %v", err)
}
if kb <= 0 {
return 0, fmt.Errorf("du size invalid: %d", kb)
}
return kb * 1024, nil
}
// getDirectoryLogicalSize walks the directory tree and sums file sizes to estimate
// the logical (Finder-style) usage.
func getDirectoryLogicalSize(path string) (int64, error) {
var total int64
err := filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error {
if err != nil {
if os.IsPermission(err) {
return filepath.SkipDir
}
return nil
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
total += info.Size()
return nil
})
if err != nil && err != filepath.SkipDir {
return 0, err
}
return total, nil
}
func snapshotFromModel(m model) historyEntry {
return historyEntry{
path: m.path,
entries: cloneDirEntries(m.entries),
largeFiles: cloneFileEntries(m.largeFiles),
totalSize: m.totalSize,
selected: m.selected,
entryOffset: m.offset,
largeSelected: m.largeSelected,
largeOffset: m.largeOffset,
}
}
func cacheSnapshot(m model) historyEntry {
entry := snapshotFromModel(m)
entry.dirty = false
return entry
}
// Persistent cache functions
func getCacheDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
cacheDir := filepath.Join(home, ".cache", "mole")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return "", err
}
return cacheDir, nil
}
func getCachePath(path string) (string, error) {
cacheDir, err := getCacheDir()
if err != nil {
return "", err
}
// Use MD5 hash of path as cache filename
hash := md5.Sum([]byte(path))
filename := fmt.Sprintf("%x.cache", hash)
return filepath.Join(cacheDir, filename), nil
}
func loadCacheFromDisk(path string) (*cacheEntry, error) {
cachePath, err := getCachePath(path)
if err != nil {
return nil, err
}
file, err := os.Open(cachePath)
if err != nil {
return nil, err
}
defer file.Close()
var entry cacheEntry
decoder := gob.NewDecoder(file)
if err := decoder.Decode(&entry); err != nil {
return nil, err
}
// Validate cache: check if directory was modified after cache creation
info, err := os.Stat(path)
if err != nil {
return nil, err
}
// If directory was modified after cache, invalidate
if info.ModTime().After(entry.ModTime) {
return nil, fmt.Errorf("cache expired: directory modified")
}
// If cache is older than 7 days, invalidate
if time.Since(entry.ScanTime) > 7*24*time.Hour {
return nil, fmt.Errorf("cache expired: too old")
}
return &entry, nil
}
func saveCacheToDisk(path string, result scanResult) error {
cachePath, err := getCachePath(path)
if err != nil {
return err
}
info, err := os.Stat(path)
if err != nil {
return err
}
entry := cacheEntry{
Entries: result.entries,
LargeFiles: result.largeFiles,
TotalSize: result.totalSize,
ModTime: info.ModTime(),
ScanTime: time.Now(),
}
file, err := os.Create(cachePath)
if err != nil {
return err
}
defer file.Close()
encoder := gob.NewEncoder(file)
return encoder.Encode(entry)
}
// getLastAccessTime returns the last access time of a file or directory (macOS only)
func getLastAccessTime(path string) time.Time {
info, err := os.Stat(path)
if err != nil {
return time.Time{}
}
// Use syscall to get atime on macOS
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return time.Time{}
}
// macOS Darwin stores atime in Atimespec
// This is guaranteed to exist on macOS due to build tag
return time.Unix(stat.Atimespec.Sec, stat.Atimespec.Nsec)
}
// formatUnusedTime formats the time since last access in a compact way
func formatUnusedTime(lastAccess time.Time) string {
if lastAccess.IsZero() {
return ""
}
duration := time.Since(lastAccess)
days := int(duration.Hours() / 24)
// Only show if unused for more than 3 months
if days < 90 {
return ""
}
months := days / 30
years := days / 365
if years >= 2 {
return fmt.Sprintf(">%dyr unused", years)
} else if years >= 1 {
return ">1yr unused"
} else if months >= 3 {
return fmt.Sprintf(">%dmo unused", months)
}
return ""
}