package zddc import ( "io/fs" "os" "path/filepath" "sort" "strings" "sync" ) // scanCache memoizes ScanZddcFiles results keyed by fsRoot. // // The walk is the dominant cost on every profile-page render in a real // archive (thousands of directories). Caching is safe because: // - The set of directories with .zddc files only changes via the writer // helpers (CreateOrUpdateFile / DeleteFile) or external file events // observed by the archive watcher; both call InvalidateScanCache. // - Cache values are immutable []string slices; readers reuse them. // // Errors are NOT cached — callers always get a fresh attempt after a walk // failure so a transient permissions blip doesn't poison the cache. var scanCache sync.Map // map[string][]string // ScanZddcFiles walks fsRoot and returns every directory that contains a // .zddc file, sorted by path. Reserved-prefix directories ('.', '_') are // pruned from the walk — they hide internal/scaffolding state from the // project picker and should likewise not surface in the editor's tree // view. fsRoot itself is not subject to the prefix rule (it is the root). // // The returned paths are absolute (rooted under fsRoot). On any walk // error the partial result accumulated so far is returned alongside the // error so callers can degrade gracefully. Successful results are cached // in memory; subsequent calls return the cached slice until // InvalidateScanCache is called (the .zddc writer helpers and the archive // watcher both invalidate on file events). func ScanZddcFiles(fsRoot string) ([]string, error) { fsRoot = filepath.Clean(fsRoot) if cached, ok := scanCache.Load(fsRoot); ok { return cached.([]string), nil } var dirs []string err := filepath.WalkDir(fsRoot, func(path string, d fs.DirEntry, walkErr error) error { if walkErr != nil { // A permission error on a single subtree shouldn't kill the walk. if os.IsPermission(walkErr) { return nil } return walkErr } if !d.IsDir() { return nil } // Prune reserved-prefix subtrees, but never the root itself. if path != fsRoot { name := d.Name() if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") { return filepath.SkipDir } } // Does this directory contain a .zddc file? if _, err := os.Stat(filepath.Join(path, ".zddc")); err == nil { dirs = append(dirs, path) } return nil }) sort.Strings(dirs) if err == nil { scanCache.Store(fsRoot, dirs) } return dirs, err } // InvalidateScanCache drops every cached ScanZddcFiles result. Called by // the writer helpers when a .zddc file is created or deleted, and by the // archive watcher on .zddc filesystem events. Modifications to existing // .zddc files don't change the scan output — but distinguishing create // from modify in fsnotify is fragile, so we treat any event as // invalidating; a single extra walk-on-next-call is cheap compared to // silent staleness. func InvalidateScanCache() { scanCache.Range(func(key, _ any) bool { scanCache.Delete(key) return true }) }