package main import ( "context" "encoding/gob" "encoding/json" "fmt" "os" "path/filepath" "sync" "time" "github.com/cespare/xxhash/v2" ) type overviewSizeSnapshot struct { Size int64 `json:"size"` Updated time.Time `json:"updated"` } var ( overviewSnapshotMu sync.Mutex overviewSnapshotCache map[string]overviewSizeSnapshot overviewSnapshotLoaded bool ) 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, IsOverview: m.isOverview, } } func cacheSnapshot(m model) historyEntry { entry := snapshotFromModel(m) entry.Dirty = false return entry } 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 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 { 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, overviewCacheFile), nil } 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 { if time.Since(snapshot.Updated) < overviewCacheTTL { return snapshot.Size, nil } return 0, fmt.Errorf("snapshot expired") } return 0, fmt.Errorf("snapshot not found") } 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 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 } hash := xxhash.Sum64String(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 } info, err := os.Stat(path) if err != nil { return nil, err } if info.ModTime().After(entry.ModTime) { // 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 { 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) } 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() } } // prefetchOverviewCache scans overview directories in background // to populate cache for faster overview mode access 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 default: } size, err := measureOverviewSize(path) if err == nil && size > 0 { _ = storeOverviewSize(path, size) } } }