1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-11 21:09:00 +00:00

Significantly optimize the speed and cache of scanning

This commit is contained in:
Tw93
2025-11-19 11:53:57 +08:00
parent c16047a3a6
commit 45c65345ac
7 changed files with 259 additions and 238 deletions

Binary file not shown.

View File

@@ -25,20 +25,20 @@ var (
func snapshotFromModel(m model) historyEntry { func snapshotFromModel(m model) historyEntry {
return historyEntry{ return historyEntry{
path: m.path, Path: m.path,
entries: cloneDirEntries(m.entries), Entries: cloneDirEntries(m.entries),
largeFiles: cloneFileEntries(m.largeFiles), LargeFiles: cloneFileEntries(m.largeFiles),
totalSize: m.totalSize, TotalSize: m.totalSize,
selected: m.selected, Selected: m.selected,
entryOffset: m.offset, EntryOffset: m.offset,
largeSelected: m.largeSelected, LargeSelected: m.largeSelected,
largeOffset: m.largeOffset, LargeOffset: m.largeOffset,
} }
} }
func cacheSnapshot(m model) historyEntry { func cacheSnapshot(m model) historyEntry {
entry := snapshotFromModel(m) entry := snapshotFromModel(m)
entry.dirty = false entry.Dirty = false
return entry return entry
} }
@@ -220,7 +220,10 @@ func loadCacheFromDisk(path string) (*cacheEntry, error) {
} }
if info.ModTime().After(entry.ModTime) { if info.ModTime().After(entry.ModTime) {
return nil, fmt.Errorf("cache expired: directory modified") // Only expire cache if the directory has been newer for longer than the grace window.
if cacheModTimeGrace <= 0 || info.ModTime().Sub(entry.ModTime) > cacheModTimeGrace {
return nil, fmt.Errorf("cache expired: directory modified")
}
} }
if time.Since(entry.ScanTime) > 7*24*time.Hour { if time.Since(entry.ScanTime) > 7*24*time.Hour {
@@ -242,9 +245,9 @@ func saveCacheToDisk(path string, result scanResult) error {
} }
entry := cacheEntry{ entry := cacheEntry{
Entries: result.entries, Entries: result.Entries,
LargeFiles: result.largeFiles, LargeFiles: result.LargeFiles,
TotalSize: result.totalSize, TotalSize: result.TotalSize,
ModTime: info.ModTime(), ModTime: info.ModTime(),
ScanTime: time.Now(), ScanTime: time.Now(),
} }
@@ -258,3 +261,29 @@ func saveCacheToDisk(path string, result scanResult) error {
encoder := gob.NewEncoder(file) encoder := gob.NewEncoder(file)
return encoder.Encode(entry) return encoder.Encode(entry)
} }
func invalidateCache(path string) {
cachePath, err := getCachePath(path)
if err == nil {
_ = os.Remove(cachePath)
}
removeOverviewSnapshot(path)
}
func removeOverviewSnapshot(path string) {
if path == "" {
return
}
overviewSnapshotMu.Lock()
defer overviewSnapshotMu.Unlock()
if err := ensureOverviewSnapshotCacheLocked(); err != nil {
return
}
if overviewSnapshotCache == nil {
return
}
if _, ok := overviewSnapshotCache[path]; ok {
delete(overviewSnapshotCache, path)
_ = persistOverviewSnapshotLocked()
}
}

View File

@@ -13,15 +13,15 @@ const (
overviewCacheFile = "overview_sizes.json" overviewCacheFile = "overview_sizes.json"
duTimeout = 60 * time.Second // Increased for large directories duTimeout = 60 * time.Second // Increased for large directories
mdlsTimeout = 5 * time.Second mdlsTimeout = 5 * time.Second
maxConcurrentOverview = 3 // Scan up to 3 overview dirs concurrently 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
batchUpdateSize = 100 // Batch atomic updates every N items cacheModTimeGrace = 30 * time.Minute // Ignore minor directory mtime bumps
// Worker pool configuration // Worker pool configuration
minWorkers = 16 // Minimum workers for better I/O throughput minWorkers = 32 // Minimum workers for better I/O throughput
maxWorkers = 128 // Maximum workers to avoid excessive goroutines maxWorkers = 256 // Maximum workers to avoid excessive goroutines
cpuMultiplier = 4 // Worker multiplier per CPU core for I/O-bound operations cpuMultiplier = 8 // Worker multiplier per CPU core for I/O-bound operations
maxDirWorkers = 32 // Maximum concurrent subdirectory scans maxDirWorkers = 64 // Maximum concurrent subdirectory scans
openCommandTimeout = 10 * time.Second // Timeout for open/reveal commands openCommandTimeout = 10 * time.Second // Timeout for open/reveal commands
) )
@@ -225,7 +225,6 @@ var spinnerFrames = []string{"|", "/", "-", "\\", "|", "/", "-", "\\"}
const ( const (
colorPurple = "\033[0;35m" colorPurple = "\033[0;35m"
colorBlue = "\033[0;34m"
colorGray = "\033[0;90m" colorGray = "\033[0;90m"
colorRed = "\033[0;31m" colorRed = "\033[0;31m"
colorYellow = "\033[1;33m" colorYellow = "\033[1;33m"
@@ -233,7 +232,4 @@ const (
colorCyan = "\033[0;36m" colorCyan = "\033[0;36m"
colorReset = "\033[0m" colorReset = "\033[0m"
colorBold = "\033[1m" colorBold = "\033[1m"
colorBgCyan = "\033[46m"
colorBgDark = "\033[100m"
colorInvert = "\033[7m"
) )

View File

@@ -16,6 +16,7 @@ func deletePathCmd(path string, counter *int64) tea.Cmd {
done: true, done: true,
err: err, err: err,
count: count, count: count,
path: path,
} }
} }
} }

View File

@@ -98,21 +98,6 @@ func humanizeBytes(size int64) string {
return fmt.Sprintf("%.1f %cB", value, "KMGTPE"[exp]) 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 { func coloredProgressBar(value, max int64, percent float64) string {
if max <= 0 { if max <= 0 {
return colorGray + strings.Repeat("░", barWidth) + colorReset return colorGray + strings.Repeat("░", barWidth) + colorReset

View File

@@ -17,23 +17,23 @@ import (
) )
type dirEntry struct { type dirEntry struct {
name string Name string
path string Path string
size int64 Size int64
isDir bool IsDir bool
lastAccess time.Time LastAccess time.Time
} }
type fileEntry struct { type fileEntry struct {
name string Name string
path string Path string
size int64 Size int64
} }
type scanResult struct { type scanResult struct {
entries []dirEntry Entries []dirEntry
largeFiles []fileEntry LargeFiles []fileEntry
totalSize int64 TotalSize int64
} }
type cacheEntry struct { type cacheEntry struct {
@@ -45,15 +45,15 @@ type cacheEntry struct {
} }
type historyEntry struct { type historyEntry struct {
path string Path string
entries []dirEntry Entries []dirEntry
largeFiles []fileEntry LargeFiles []fileEntry
totalSize int64 TotalSize int64
selected int Selected int
entryOffset int EntryOffset int
largeSelected int LargeSelected int
largeOffset int LargeOffset int
dirty bool Dirty bool
} }
type scanResultMsg struct { type scanResultMsg struct {
@@ -62,10 +62,10 @@ type scanResultMsg struct {
} }
type overviewSizeMsg struct { type overviewSizeMsg struct {
path string Path string
index int Index int
size int64 Size int64
err error Err error
} }
type tickMsg time.Time type tickMsg time.Time
@@ -74,6 +74,7 @@ type deleteProgressMsg struct {
done bool done bool
err error err error
count int64 count int64
path string
} }
type model struct { type model struct {
@@ -109,6 +110,10 @@ type model struct {
overviewScanningSet map[string]bool // Track which paths are currently being scanned overviewScanningSet map[string]bool // Track which paths are currently being scanned
} }
func (m model) inOverviewMode() bool {
return m.isOverview && m.path == "/"
}
func main() { func main() {
target := os.Getenv("MO_ANALYZE_PATH") target := os.Getenv("MO_ANALYZE_PATH")
if target == "" && len(os.Args) > 1 { if target == "" && len(os.Args) > 1 {
@@ -188,19 +193,19 @@ func createOverviewEntries() []dirEntry {
if home != "" { if home != "" {
entries = append(entries, entries = append(entries,
dirEntry{name: "Home (~)", path: home, isDir: true, size: -1}, dirEntry{Name: "Home (~)", Path: home, IsDir: true, Size: -1},
dirEntry{name: "Library (~/Library)", path: filepath.Join(home, "Library"), isDir: true, size: -1}, dirEntry{Name: "Library (~/Library)", Path: filepath.Join(home, "Library"), IsDir: true, Size: -1},
) )
} }
entries = append(entries, entries = append(entries,
dirEntry{name: "Applications", path: "/Applications", isDir: true, size: -1}, dirEntry{Name: "Applications", Path: "/Applications", IsDir: true, Size: -1},
dirEntry{name: "System Library", path: "/Library", isDir: true, size: -1}, dirEntry{Name: "System Library", Path: "/Library", IsDir: true, Size: -1},
) )
// Add Volumes shortcut only when it contains real mounted folders (e.g., external disks) // Add Volumes shortcut only when it contains real mounted folders (e.g., external disks)
if hasUsefulVolumeMounts("/Volumes") { if hasUsefulVolumeMounts("/Volumes") {
entries = append(entries, dirEntry{name: "Volumes", path: "/Volumes", isDir: true, size: -1}) entries = append(entries, dirEntry{Name: "Volumes", Path: "/Volumes", IsDir: true, Size: -1})
} }
return entries return entries
@@ -239,27 +244,27 @@ func (m *model) hydrateOverviewEntries() {
m.overviewSizeCache = make(map[string]int64) m.overviewSizeCache = make(map[string]int64)
} }
for i := range m.entries { for i := range m.entries {
if size, ok := m.overviewSizeCache[m.entries[i].path]; ok { if size, ok := m.overviewSizeCache[m.entries[i].Path]; ok {
m.entries[i].size = size m.entries[i].Size = size
continue continue
} }
if size, err := loadOverviewCachedSize(m.entries[i].path); err == nil { if size, err := loadOverviewCachedSize(m.entries[i].Path); err == nil {
m.entries[i].size = size m.entries[i].Size = size
m.overviewSizeCache[m.entries[i].path] = size m.overviewSizeCache[m.entries[i].Path] = size
} }
} }
m.totalSize = sumKnownEntrySizes(m.entries) m.totalSize = sumKnownEntrySizes(m.entries)
} }
func (m *model) scheduleOverviewScans() tea.Cmd { func (m *model) scheduleOverviewScans() tea.Cmd {
if !m.isOverview { if !m.inOverviewMode() {
return nil return nil
} }
// Find pending entries (not scanned and not currently scanning) // Find pending entries (not scanned and not currently scanning)
var pendingIndices []int var pendingIndices []int
for i, entry := range m.entries { for i, entry := range m.entries {
if entry.size < 0 && !m.overviewScanningSet[entry.path] { if entry.Size < 0 && !m.overviewScanningSet[entry.Path] {
pendingIndices = append(pendingIndices, i) pendingIndices = append(pendingIndices, i)
if len(pendingIndices) >= maxConcurrentOverview { if len(pendingIndices) >= maxConcurrentOverview {
break break
@@ -280,22 +285,22 @@ func (m *model) scheduleOverviewScans() tea.Cmd {
var cmds []tea.Cmd var cmds []tea.Cmd
for _, idx := range pendingIndices { for _, idx := range pendingIndices {
entry := m.entries[idx] entry := m.entries[idx]
m.overviewScanningSet[entry.path] = true m.overviewScanningSet[entry.Path] = true
cmd := scanOverviewPathCmd(entry.path, idx) cmd := scanOverviewPathCmd(entry.Path, idx)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
} }
m.overviewScanning = true m.overviewScanning = true
remaining := 0 remaining := 0
for _, e := range m.entries { for _, e := range m.entries {
if e.size < 0 { if e.Size < 0 {
remaining++ remaining++
} }
} }
if len(pendingIndices) > 0 { if len(pendingIndices) > 0 {
firstEntry := m.entries[pendingIndices[0]] firstEntry := m.entries[pendingIndices[0]]
if len(pendingIndices) == 1 { if len(pendingIndices) == 1 {
m.status = fmt.Sprintf("Scanning %s... (%d left)", firstEntry.name, remaining) m.status = fmt.Sprintf("Scanning %s... (%d left)", firstEntry.Name, remaining)
} else { } else {
m.status = fmt.Sprintf("Scanning %d directories... (%d left)", len(pendingIndices), remaining) m.status = fmt.Sprintf("Scanning %d directories... (%d left)", len(pendingIndices), remaining)
} }
@@ -305,21 +310,6 @@ func (m *model) scheduleOverviewScans() tea.Cmd {
return tea.Batch(cmds...) 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) { func (m *model) getScanProgress() (files, dirs, bytes int64) {
if m.filesScanned != nil { if m.filesScanned != nil {
files = atomic.LoadInt64(m.filesScanned) files = atomic.LoadInt64(m.filesScanned)
@@ -333,21 +323,8 @@ func (m *model) getScanProgress() (files, dirs, bytes int64) {
return 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 { func (m model) Init() tea.Cmd {
if m.isOverview { if m.inOverviewMode() {
return m.scheduleOverviewScans() return m.scheduleOverviewScans()
} }
return tea.Batch(m.scanCmd(m.path), tickCmd()) return tea.Batch(m.scanCmd(m.path), tickCmd())
@@ -358,9 +335,9 @@ func (m model) scanCmd(path string) tea.Cmd {
// Try to load from persistent cache first // Try to load from persistent cache first
if cached, err := loadCacheFromDisk(path); err == nil { if cached, err := loadCacheFromDisk(path); err == nil {
result := scanResult{ result := scanResult{
entries: cached.Entries, Entries: cached.Entries,
largeFiles: cached.LargeFiles, LargeFiles: cached.LargeFiles,
totalSize: cached.TotalSize, TotalSize: cached.TotalSize,
} }
return scanResultMsg{result: result, err: nil} return scanResultMsg{result: result, err: nil}
} }
@@ -405,14 +382,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.err != nil { if msg.err != nil {
m.status = fmt.Sprintf("Failed to delete: %v", msg.err) m.status = fmt.Sprintf("Failed to delete: %v", msg.err)
} else { } else {
if msg.path != "" {
m.removePathFromView(msg.path)
invalidateCache(msg.path)
}
invalidateCache(m.path)
m.status = fmt.Sprintf("Deleted %d items", msg.count) m.status = fmt.Sprintf("Deleted %d items", msg.count)
// Mark all caches as dirty // Mark all caches as dirty
for i := range m.history { for i := range m.history {
m.history[i].dirty = true m.history[i].Dirty = true
} }
for path := range m.cache { for path := range m.cache {
entry := m.cache[path] entry := m.cache[path]
entry.dirty = true entry.Dirty = true
m.cache[path] = entry m.cache[path] = entry
} }
// Refresh the view // Refresh the view
@@ -427,9 +409,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.status = fmt.Sprintf("Scan failed: %v", msg.err) m.status = fmt.Sprintf("Scan failed: %v", msg.err)
return m, nil return m, nil
} }
m.entries = msg.result.entries m.entries = msg.result.Entries
m.largeFiles = msg.result.largeFiles m.largeFiles = msg.result.LargeFiles
m.totalSize = msg.result.totalSize m.totalSize = msg.result.TotalSize
m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize))
m.clampEntrySelection() m.clampEntrySelection()
m.clampLargeSelection() m.clampLargeSelection()
@@ -446,23 +428,23 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case overviewSizeMsg: case overviewSizeMsg:
// Remove from scanning set // Remove from scanning set
delete(m.overviewScanningSet, msg.path) delete(m.overviewScanningSet, msg.Path)
if msg.err == nil { if msg.Err == nil {
if m.overviewSizeCache == nil { if m.overviewSizeCache == nil {
m.overviewSizeCache = make(map[string]int64) m.overviewSizeCache = make(map[string]int64)
} }
m.overviewSizeCache[msg.path] = msg.size m.overviewSizeCache[msg.Path] = msg.Size
} }
if m.isOverview { if m.inOverviewMode() {
// Update entry with result // Update entry with result
for i := range m.entries { for i := range m.entries {
if m.entries[i].path == msg.path { if m.entries[i].Path == msg.Path {
if msg.err == nil { if msg.Err == nil {
m.entries[i].size = msg.size m.entries[i].Size = msg.Size
} else { } else {
m.entries[i].size = 0 m.entries[i].Size = 0
} }
break break
} }
@@ -470,8 +452,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.totalSize = sumKnownEntrySizes(m.entries) m.totalSize = sumKnownEntrySizes(m.entries)
// Show error briefly if any // Show error briefly if any
if msg.err != nil { if msg.Err != nil {
m.status = fmt.Sprintf("Unable to measure %s: %v", displayPath(msg.path), msg.err) m.status = fmt.Sprintf("Unable to measure %s: %v", displayPath(msg.Path), msg.Err)
} }
// Schedule next batch of scans // Schedule next batch of scans
@@ -482,15 +464,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tickMsg: case tickMsg:
// Keep spinner running if scanning or deleting or if there are pending overview items // Keep spinner running if scanning or deleting or if there are pending overview items
hasPending := false hasPending := false
if m.isOverview { if m.inOverviewMode() {
for _, entry := range m.entries { for _, entry := range m.entries {
if entry.size < 0 { if entry.Size < 0 {
hasPending = true hasPending = true
break break
} }
} }
} }
if m.scanning || m.deleting || (m.isOverview && (m.overviewScanning || hasPending)) { if m.scanning || m.deleting || (m.inOverviewMode() && (m.overviewScanning || hasPending)) {
m.spinner = (m.spinner + 1) % len(spinnerFrames) m.spinner = (m.spinner + 1) % len(spinnerFrames)
// Update delete progress status // Update delete progress status
if m.deleting && m.deleteCount != nil { if m.deleting && m.deleteCount != nil {
@@ -517,8 +499,8 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.deleting = true m.deleting = true
var deleteCount int64 var deleteCount int64
m.deleteCount = &deleteCount m.deleteCount = &deleteCount
targetPath := m.deleteTarget.path targetPath := m.deleteTarget.Path
targetName := m.deleteTarget.name targetName := m.deleteTarget.Name
m.deleteTarget = nil m.deleteTarget = nil
m.status = fmt.Sprintf("Deleting %s...", targetName) m.status = fmt.Sprintf("Deleting %s...", targetName)
return m, tea.Batch(deletePathCmd(targetPath, m.deleteCount), tickCmd()) return m, tea.Batch(deletePathCmd(targetPath, m.deleteCount), tickCmd())
@@ -595,27 +577,27 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
if len(m.history) == 0 { if len(m.history) == 0 {
// Return to overview if at top level // Return to overview if at top level
if !m.isOverview { if !m.inOverviewMode() {
return m, m.switchToOverviewMode() return m, m.switchToOverviewMode()
} }
return m, nil return m, nil
} }
last := m.history[len(m.history)-1] last := m.history[len(m.history)-1]
m.history = m.history[:len(m.history)-1] m.history = m.history[:len(m.history)-1]
m.path = last.path m.path = last.Path
m.selected = last.selected m.selected = last.Selected
m.offset = last.entryOffset m.offset = last.EntryOffset
m.largeSelected = last.largeSelected m.largeSelected = last.LargeSelected
m.largeOffset = last.largeOffset m.largeOffset = last.LargeOffset
m.isOverview = false m.isOverview = false
if last.dirty { if last.Dirty {
m.status = "Scanning..." m.status = "Scanning..."
m.scanning = true m.scanning = true
return m, tea.Batch(m.scanCmd(m.path), tickCmd()) return m, tea.Batch(m.scanCmd(m.path), tickCmd())
} }
m.entries = last.entries m.entries = last.Entries
m.largeFiles = last.largeFiles m.largeFiles = last.LargeFiles
m.totalSize = last.totalSize m.totalSize = last.TotalSize
m.clampEntrySelection() m.clampEntrySelection()
m.clampLargeSelection() m.clampLargeSelection()
if len(m.entries) == 0 { if len(m.entries) == 0 {
@@ -648,8 +630,8 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
defer cancel() defer cancel()
_ = exec.CommandContext(ctx, "open", path).Run() _ = exec.CommandContext(ctx, "open", path).Run()
}(selected.path) }(selected.Path)
m.status = fmt.Sprintf("Opening %s...", selected.name) m.status = fmt.Sprintf("Opening %s...", selected.Name)
} }
} else if len(m.entries) > 0 { } else if len(m.entries) > 0 {
selected := m.entries[m.selected] selected := m.entries[m.selected]
@@ -657,8 +639,8 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
defer cancel() defer cancel()
_ = exec.CommandContext(ctx, "open", path).Run() _ = exec.CommandContext(ctx, "open", path).Run()
}(selected.path) }(selected.Path)
m.status = fmt.Sprintf("Opening %s...", selected.name) m.status = fmt.Sprintf("Opening %s...", selected.Name)
} }
case "f", "F": case "f", "F":
// Reveal selected entry in Finder // Reveal selected entry in Finder
@@ -669,8 +651,8 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
defer cancel() defer cancel()
_ = exec.CommandContext(ctx, "open", "-R", path).Run() _ = exec.CommandContext(ctx, "open", "-R", path).Run()
}(selected.path) }(selected.Path)
m.status = fmt.Sprintf("Revealing %s in Finder...", selected.name) m.status = fmt.Sprintf("Revealing %s in Finder...", selected.Name)
} }
} else if len(m.entries) > 0 { } else if len(m.entries) > 0 {
selected := m.entries[m.selected] selected := m.entries[m.selected]
@@ -678,8 +660,8 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
defer cancel() defer cancel()
_ = exec.CommandContext(ctx, "open", "-R", path).Run() _ = exec.CommandContext(ctx, "open", "-R", path).Run()
}(selected.path) }(selected.Path)
m.status = fmt.Sprintf("Revealing %s in Finder...", selected.name) m.status = fmt.Sprintf("Revealing %s in Finder...", selected.Name)
} }
case "delete", "backspace": case "delete", "backspace":
// Delete selected file or directory // Delete selected file or directory
@@ -688,13 +670,13 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
selected := m.largeFiles[m.largeSelected] selected := m.largeFiles[m.largeSelected]
m.deleteConfirm = true m.deleteConfirm = true
m.deleteTarget = &dirEntry{ m.deleteTarget = &dirEntry{
name: selected.name, Name: selected.Name,
path: selected.path, Path: selected.Path,
size: selected.size, Size: selected.Size,
isDir: false, IsDir: false,
} }
} }
} else if len(m.entries) > 0 && !m.isOverview { } else if len(m.entries) > 0 && !m.inOverviewMode() {
selected := m.entries[m.selected] selected := m.entries[m.selected]
m.deleteConfirm = true m.deleteConfirm = true
m.deleteTarget = &selected m.deleteTarget = &selected
@@ -730,11 +712,11 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
selected := m.entries[m.selected] selected := m.entries[m.selected]
if selected.isDir { if selected.IsDir {
if !m.isOverview { if !m.inOverviewMode() {
m.history = append(m.history, snapshotFromModel(m)) m.history = append(m.history, snapshotFromModel(m))
} }
m.path = selected.path m.path = selected.Path
m.selected = 0 m.selected = 0
m.offset = 0 m.offset = 0
m.status = "Scanning..." m.status = "Scanning..."
@@ -749,14 +731,14 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) {
*m.currentPath = "" *m.currentPath = ""
} }
if cached, ok := m.cache[m.path]; ok && !cached.dirty { if cached, ok := m.cache[m.path]; ok && !cached.Dirty {
m.entries = cloneDirEntries(cached.entries) m.entries = cloneDirEntries(cached.Entries)
m.largeFiles = cloneFileEntries(cached.largeFiles) m.largeFiles = cloneFileEntries(cached.LargeFiles)
m.totalSize = cached.totalSize m.totalSize = cached.TotalSize
m.selected = cached.selected m.selected = cached.Selected
m.offset = cached.entryOffset m.offset = cached.EntryOffset
m.largeSelected = cached.largeSelected m.largeSelected = cached.LargeSelected
m.largeOffset = cached.largeOffset m.largeOffset = cached.LargeOffset
m.clampEntrySelection() m.clampEntrySelection()
m.clampLargeSelection() m.clampLargeSelection()
m.status = fmt.Sprintf("Cached view for %s", displayPath(m.path)) m.status = fmt.Sprintf("Cached view for %s", displayPath(m.path))
@@ -765,7 +747,7 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) {
} }
return m, tea.Batch(m.scanCmd(m.path), tickCmd()) return m, tea.Batch(m.scanCmd(m.path), tickCmd())
} }
m.status = fmt.Sprintf("File: %s (%s)", selected.name, humanizeBytes(selected.size)) m.status = fmt.Sprintf("File: %s (%s)", selected.Name, humanizeBytes(selected.Size))
return m, nil return m, nil
} }
@@ -773,19 +755,13 @@ func (m model) View() string {
var b strings.Builder var b strings.Builder
fmt.Fprintln(&b) fmt.Fprintln(&b)
if m.deleteConfirm && m.deleteTarget != nil { if m.inOverviewMode() {
// Show delete confirmation prominently at the top
fmt.Fprintf(&b, "%sDelete: %s (%s)? Press Delete again to confirm, ESC to cancel%s\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, "%sAnalyze Disk%s\n", colorPurple, colorReset)
if m.overviewScanning { if m.overviewScanning {
// Check if we're in initial scan (all entries are pending) // Check if we're in initial scan (all entries are pending)
allPending := true allPending := true
for _, entry := range m.entries { for _, entry := range m.entries {
if entry.size >= 0 { if entry.Size >= 0 {
allPending = false allPending = false
break break
} }
@@ -807,7 +783,7 @@ func (m model) View() string {
// Check if there are still pending items // Check if there are still pending items
hasPending := false hasPending := false
for _, entry := range m.entries { for _, entry := range m.entries {
if entry.size < 0 { if entry.Size < 0 {
hasPending = true hasPending = true
break break
} }
@@ -880,13 +856,13 @@ func (m model) View() string {
} }
maxLargeSize := int64(1) maxLargeSize := int64(1)
for _, file := range m.largeFiles { for _, file := range m.largeFiles {
if file.size > maxLargeSize { if file.Size > maxLargeSize {
maxLargeSize = file.size maxLargeSize = file.Size
} }
} }
for idx := start; idx < end; idx++ { for idx := start; idx < end; idx++ {
file := m.largeFiles[idx] file := m.largeFiles[idx]
shortPath := displayPath(file.path) shortPath := displayPath(file.Path)
shortPath = truncateMiddle(shortPath, 35) shortPath = truncateMiddle(shortPath, 35)
paddedPath := padName(shortPath, 35) paddedPath := padName(shortPath, 35)
entryPrefix := " " entryPrefix := " "
@@ -899,8 +875,8 @@ func (m model) View() string {
sizeColor = colorCyan sizeColor = colorCyan
numColor = colorCyan numColor = colorCyan
} }
size := humanizeBytes(file.size) size := humanizeBytes(file.Size)
bar := coloredProgressBar(file.size, maxLargeSize, 0) bar := coloredProgressBar(file.Size, maxLargeSize, 0)
fmt.Fprintf(&b, "%s%s%2d.%s %s | 📄 %s%s%s %s%10s%s\n", fmt.Fprintf(&b, "%s%s%2d.%s %s | 📄 %s%s%s %s%10s%s\n",
entryPrefix, numColor, idx+1, colorReset, bar, nameColor, paddedPath, colorReset, sizeColor, size, colorReset) entryPrefix, numColor, idx+1, colorReset, bar, nameColor, paddedPath, colorReset, sizeColor, size, colorReset)
} }
@@ -909,17 +885,17 @@ func (m model) View() string {
if len(m.entries) == 0 { if len(m.entries) == 0 {
fmt.Fprintln(&b, " Empty directory") fmt.Fprintln(&b, " Empty directory")
} else { } else {
if m.isOverview { if m.inOverviewMode() {
maxSize := int64(1) maxSize := int64(1)
for _, entry := range m.entries { for _, entry := range m.entries {
if entry.size > maxSize { if entry.Size > maxSize {
maxSize = entry.size maxSize = entry.Size
} }
} }
totalSize := m.totalSize totalSize := m.totalSize
for idx, entry := range m.entries { for idx, entry := range m.entries {
icon := "📁" icon := "📁"
sizeVal := entry.size sizeVal := entry.Size
barValue := sizeVal barValue := sizeVal
if barValue < 0 { if barValue < 0 {
barValue = 0 barValue = 0
@@ -953,7 +929,7 @@ func (m model) View() string {
} }
} }
entryPrefix := " " entryPrefix := " "
name := trimName(entry.name) name := trimName(entry.Name)
paddedName := padName(name, 28) paddedName := padName(name, 28)
nameSegment := fmt.Sprintf("%s %s", icon, paddedName) nameSegment := fmt.Sprintf("%s %s", icon, paddedName)
numColor := "" numColor := ""
@@ -969,9 +945,9 @@ func (m model) View() string {
// Add unused time label if applicable // Add unused time label if applicable
// For overview mode, get access time on-demand if not set // For overview mode, get access time on-demand if not set
lastAccess := entry.lastAccess lastAccess := entry.LastAccess
if lastAccess.IsZero() && entry.path != "" { if lastAccess.IsZero() && entry.Path != "" {
lastAccess = getLastAccessTime(entry.path) lastAccess = getLastAccessTime(entry.Path)
} }
unusedLabel := formatUnusedTime(lastAccess) unusedLabel := formatUnusedTime(lastAccess)
if unusedLabel == "" { if unusedLabel == "" {
@@ -989,8 +965,8 @@ func (m model) View() string {
// Normal mode with sizes and progress bars // Normal mode with sizes and progress bars
maxSize := int64(1) maxSize := int64(1)
for _, entry := range m.entries { for _, entry := range m.entries {
if entry.size > maxSize { if entry.Size > maxSize {
maxSize = entry.size maxSize = entry.Size
} }
} }
@@ -1006,19 +982,19 @@ func (m model) View() string {
for idx := start; idx < end; idx++ { for idx := start; idx < end; idx++ {
entry := m.entries[idx] entry := m.entries[idx]
icon := "📄" icon := "📄"
if entry.isDir { if entry.IsDir {
icon = "📁" icon = "📁"
} }
size := humanizeBytes(entry.size) size := humanizeBytes(entry.Size)
name := trimName(entry.name) name := trimName(entry.Name)
paddedName := padName(name, 28) paddedName := padName(name, 28)
// Calculate percentage // Calculate percentage
percent := float64(entry.size) / float64(m.totalSize) * 100 percent := float64(entry.Size) / float64(m.totalSize) * 100
percentStr := fmt.Sprintf("%5.1f%%", percent) percentStr := fmt.Sprintf("%5.1f%%", percent)
// Get colored progress bar // Get colored progress bar
bar := coloredProgressBar(entry.size, maxSize, percent) bar := coloredProgressBar(entry.Size, maxSize, percent)
// Color the size based on magnitude // Color the size based on magnitude
var sizeColor string var sizeColor string
@@ -1048,7 +1024,7 @@ func (m model) View() string {
displayIndex := idx + 1 displayIndex := idx + 1
// Add unused time label if applicable // Add unused time label if applicable
unusedLabel := formatUnusedTime(entry.lastAccess) unusedLabel := formatUnusedTime(entry.LastAccess)
if unusedLabel == "" { if unusedLabel == "" {
fmt.Fprintf(&b, "%s%s%2d.%s %s %s%s%s | %s %s%10s%s\n", fmt.Fprintf(&b, "%s%s%2d.%s %s %s%s%s | %s %s%10s%s\n",
entryPrefix, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset, entryPrefix, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset,
@@ -1065,7 +1041,7 @@ func (m model) View() string {
} }
fmt.Fprintln(&b) fmt.Fprintln(&b)
if m.isOverview { if m.inOverviewMode() {
fmt.Fprintf(&b, "%s↑/↓ Nav | Enter | O Open | F Reveal | Q Quit%s\n", colorGray, colorReset) fmt.Fprintf(&b, "%s↑/↓ Nav | Enter | O Open | F Reveal | Q Quit%s\n", colorGray, colorReset)
} else if m.showLargeFiles { } else if m.showLargeFiles {
fmt.Fprintf(&b, "%s↑/↓ Nav | O Open | F Reveal | ⌫ Delete | L Back | Q Quit%s\n", colorGray, colorReset) fmt.Fprintf(&b, "%s↑/↓ Nav | O Open | F Reveal | ⌫ Delete | L Back | Q Quit%s\n", colorGray, colorReset)
@@ -1077,6 +1053,13 @@ func (m model) View() string {
fmt.Fprintf(&b, "%s↑/↓/←/→ Nav | Enter | O Open | F Reveal | ⌫ Delete | Q Quit%s\n", colorGray, colorReset) fmt.Fprintf(&b, "%s↑/↓/←/→ Nav | Enter | O Open | F Reveal | ⌫ Delete | Q Quit%s\n", colorGray, colorReset)
} }
} }
if m.deleteConfirm && m.deleteTarget != nil {
fmt.Fprintln(&b)
fmt.Fprintf(&b, "%sDelete:%s %s (%s) %sConfirm: Delete | Cancel: ESC%s\n",
colorRed, colorReset,
m.deleteTarget.Name, humanizeBytes(m.deleteTarget.Size),
colorGray, colorReset)
}
return b.String() return b.String()
} }
@@ -1137,8 +1120,8 @@ func (m *model) clampLargeSelection() {
func sumKnownEntrySizes(entries []dirEntry) int64 { func sumKnownEntrySizes(entries []dirEntry) int64 {
var total int64 var total int64
for _, entry := range entries { for _, entry := range entries {
if entry.size > 0 { if entry.Size > 0 {
total += entry.size total += entry.Size
} }
} }
return total return total
@@ -1146,7 +1129,7 @@ func sumKnownEntrySizes(entries []dirEntry) int64 {
func nextPendingOverviewIndex(entries []dirEntry) int { func nextPendingOverviewIndex(entries []dirEntry) int {
for i, entry := range entries { for i, entry := range entries {
if entry.size < 0 { if entry.Size < 0 {
return i return i
} }
} }
@@ -1155,21 +1138,55 @@ func nextPendingOverviewIndex(entries []dirEntry) int {
func hasPendingOverviewEntries(entries []dirEntry) bool { func hasPendingOverviewEntries(entries []dirEntry) bool {
for _, entry := range entries { for _, entry := range entries {
if entry.size < 0 { if entry.Size < 0 {
return true return true
} }
} }
return false return false
} }
func (m *model) removePathFromView(path string) {
if path == "" {
return
}
var removedSize int64
for i, entry := range m.entries {
if entry.Path == path {
if entry.Size > 0 {
removedSize = entry.Size
}
m.entries = append(m.entries[:i], m.entries[i+1:]...)
break
}
}
for i := 0; i < len(m.largeFiles); i++ {
if m.largeFiles[i].Path == path {
m.largeFiles = append(m.largeFiles[:i], m.largeFiles[i+1:]...)
break
}
}
if removedSize > 0 {
if removedSize > m.totalSize {
m.totalSize = 0
} else {
m.totalSize -= removedSize
}
m.clampEntrySelection()
}
m.clampLargeSelection()
}
func scanOverviewPathCmd(path string, index int) tea.Cmd { func scanOverviewPathCmd(path string, index int) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
size, err := measureOverviewSize(path) size, err := measureOverviewSize(path)
return overviewSizeMsg{ return overviewSizeMsg{
path: path, Path: path,
index: index, Index: index,
size: size, Size: size,
err: err, Err: err,
} }
} }
} }

View File

@@ -99,11 +99,11 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
atomic.AddInt64(dirsScanned, 1) atomic.AddInt64(dirsScanned, 1)
entryChan <- dirEntry{ entryChan <- dirEntry{
name: name, Name: name,
path: path, Path: path,
size: size, Size: size,
isDir: true, IsDir: true,
lastAccess: time.Time{}, // Lazy load when displayed LastAccess: time.Time{}, // Lazy load when displayed
} }
}(child.Name(), fullPath) }(child.Name(), fullPath)
continue continue
@@ -121,11 +121,11 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
atomic.AddInt64(dirsScanned, 1) atomic.AddInt64(dirsScanned, 1)
entryChan <- dirEntry{ entryChan <- dirEntry{
name: name, Name: name,
path: path, Path: path,
size: size, Size: size,
isDir: true, IsDir: true,
lastAccess: time.Time{}, // Lazy load when displayed LastAccess: time.Time{}, // Lazy load when displayed
} }
}(child.Name(), fullPath) }(child.Name(), fullPath)
continue continue
@@ -142,15 +142,15 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
atomic.AddInt64(bytesScanned, size) atomic.AddInt64(bytesScanned, size)
entryChan <- dirEntry{ entryChan <- dirEntry{
name: child.Name(), Name: child.Name(),
path: fullPath, Path: fullPath,
size: size, Size: size,
isDir: false, IsDir: false,
lastAccess: getLastAccessTimeFromInfo(info), LastAccess: getLastAccessTimeFromInfo(info),
} }
// Only track large files that are not code/text files // Only track large files that are not code/text files
if !shouldSkipFileForLargeTracking(fullPath) && size >= minLargeFileSize { if !shouldSkipFileForLargeTracking(fullPath) && size >= minLargeFileSize {
largeFileChan <- fileEntry{name: child.Name(), path: fullPath, size: size} largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}
} }
} }
@@ -162,7 +162,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
collectorWg.Wait() collectorWg.Wait()
sort.Slice(entries, func(i, j int) bool { sort.Slice(entries, func(i, j int) bool {
return entries[i].size > entries[j].size return entries[i].Size > entries[j].Size
}) })
if len(entries) > maxEntries { if len(entries) > maxEntries {
entries = entries[:maxEntries] entries = entries[:maxEntries]
@@ -174,7 +174,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
} else { } else {
// Sort and trim large files collected from scanning // Sort and trim large files collected from scanning
sort.Slice(largeFiles, func(i, j int) bool { sort.Slice(largeFiles, func(i, j int) bool {
return largeFiles[i].size > largeFiles[j].size return largeFiles[i].Size > largeFiles[j].Size
}) })
if len(largeFiles) > maxLargeFiles { if len(largeFiles) > maxLargeFiles {
largeFiles = largeFiles[:maxLargeFiles] largeFiles = largeFiles[:maxLargeFiles]
@@ -182,17 +182,12 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
} }
return scanResult{ return scanResult{
entries: entries, Entries: entries,
largeFiles: largeFiles, LargeFiles: largeFiles,
totalSize: total, TotalSize: total,
}, nil }, nil
} }
func shouldFoldDir(name string) bool {
return foldDirs[name]
}
// shouldFoldDirWithPath checks if a directory should be folded based on path context
func shouldFoldDirWithPath(name, path string) bool { func shouldFoldDirWithPath(name, path string) bool {
// Check basic fold list first // Check basic fold list first
if foldDirs[name] { if foldDirs[name] {
@@ -217,7 +212,6 @@ func shouldFoldDirWithPath(name, path string) bool {
return false return false
} }
func shouldSkipFileForLargeTracking(path string) bool { func shouldSkipFileForLargeTracking(path string) bool {
ext := strings.ToLower(filepath.Ext(path)) ext := strings.ToLower(filepath.Ext(path))
return skipExtensions[ext] return skipExtensions[ext]
@@ -338,15 +332,15 @@ func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
// Get actual disk usage for sparse files and cloud files // Get actual disk usage for sparse files and cloud files
actualSize := getActualFileSize(line, info) actualSize := getActualFileSize(line, info)
files = append(files, fileEntry{ files = append(files, fileEntry{
name: filepath.Base(line), Name: filepath.Base(line),
path: line, Path: line,
size: actualSize, Size: actualSize,
}) })
} }
// Sort by size (descending) // Sort by size (descending)
sort.Slice(files, func(i, j int) bool { sort.Slice(files, func(i, j int) bool {
return files[i].size > files[j].size return files[i].Size > files[j].Size
}) })
// Return top N // Return top N
@@ -433,7 +427,7 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil
// Track large files // Track large files
if !shouldSkipFileForLargeTracking(fullPath) && size >= minLargeFileSize { if !shouldSkipFileForLargeTracking(fullPath) && size >= minLargeFileSize {
largeFileChan <- fileEntry{name: child.Name(), path: fullPath, size: size} largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}
} }
// Update current path // Update current path
@@ -483,7 +477,6 @@ func measureOverviewSize(path string) (int64, error) {
return 0, fmt.Errorf("unable to measure directory size with fast methods") return 0, fmt.Errorf("unable to measure directory size with fast methods")
} }
func getDirectorySizeFromDu(path string) (int64, error) { func getDirectorySizeFromDu(path string) (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), duTimeout) ctx, cancel := context.WithTimeout(context.Background(), duTimeout)
defer cancel() defer cancel()