package browser import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "time" ) // DirectoryIndex holds a cached index of a directory's contents. // Per design: index stores raw metadata; sorting is pre-computed via position maps. type DirectoryIndex struct { Path string `json:"path"` Mtime time.Time `json:"mtime"` EntryCount int `json:"entry_count"` Entries []Entry `json:"entries"` SortOrders map[string][]int `json:"sort_orders,omitempty"` // key: "name_asc", "name_desc", etc. } // getCachePath returns the path to the cached index file for a directory. func getCachePath(dirPath string) string { // Use .pad/indices/ relative to the directory being indexed indicesDir := filepath.Join(dirPath, ".pad", "indices") // Simple hash: use the directory path itself (URL-encoded style) encoded := strings.NewReplacer("/", "_", "\\", "_").Replace(dirPath) return filepath.Join(indicesDir, fmt.Sprintf("browser_%s.json", encoded)) } // buildIndex reads the directory and produces an index with pre-computed position maps. // Sorting happens at index build time, not during frame rendering. func buildIndex(dirPath string) (*DirectoryIndex, error) { // Try to load from cache first if cached, ok := loadCachedIndex(dirPath); ok { return cached, nil } // 1. Read directory entries entries, err := os.ReadDir(dirPath) if err != nil { return nil, fmt.Errorf("browser: read dir %s: %w", dirPath, err) } // 2. Convert to Entry structs (unsorted) var browserEntries []Entry for _, e := range entries { // Skip hidden directories like .pad if e.Name() == ".pad" || e.Name() == ".git" { continue } info, err := e.Info() if err != nil { continue } browserEntries = append(browserEntries, Entry{ Path: filepath.Join(dirPath, e.Name()), Name: e.Name(), Size: info.Size(), ModTime: info.ModTime(), IsDir: info.IsDir(), }) } // 3. Build position maps for all sort modes sortOrders := make(map[string][]int) for mode := SortMode(0); mode < SortMode(modeCount()); mode++ { key := sortModeKey(mode) if key != "" { sortOrders[key] = buildPositionMap(browserEntries, mode) } } // 4. Build index (unsorted entries with pre-computed position maps) dirInfo, err := os.Stat(dirPath) if err != nil { return nil, fmt.Errorf("browser: stat dir %s: %w", dirPath, err) } idx := &DirectoryIndex{ Path: dirPath, Mtime: dirInfo.ModTime(), EntryCount: len(browserEntries), Entries: browserEntries, SortOrders: sortOrders, } // 5. Write to cache cacheIndex(idx) return idx, nil } // buildPositionMap creates a sorted index → raw index mapping. // The returned slice maps sorted positions to raw entry indices. // Uses sort.SliceStable for O(n log n) performance on large directories. func buildPositionMap(entries []Entry, mode SortMode) []int { n := len(entries) indices := make([]int, n) for i := range indices { indices[i] = i } // Sort indices based on entry comparison cmp := comparator(mode) sort.SliceStable(indices, func(i, j int) bool { a, b := entries[indices[i]], entries[indices[j]] return cmp(a, b) < 0 }) return indices } // sortModeKey converts a SortMode to its JSON key string. func sortModeKey(mode SortMode) string { switch mode { case SortModeNameAsc: return "name_asc" case SortModeNameDesc: return "name_desc" case SortModeDateAsc: return "date_asc" case SortModeDateDesc: return "date_desc" default: return "" } } // modeCount returns the total number of sort modes (4). func modeCount() int { return int(SortModeDateDesc) + 1 } // loadCachedIndex attempts to load a cached index from disk. // Returns the index and true if valid, or nil and false if not. // Cache validation uses both mtime and entry count to handle // filesystems where mtime doesn't update immediately. func loadCachedIndex(dirPath string) (*DirectoryIndex, bool) { cachePath := getCachePath(dirPath) data, err := os.ReadFile(cachePath) if err != nil { return nil, false } var idx DirectoryIndex if err := json.Unmarshal(data, &idx); err != nil { return nil, false } // Check if directory mtime has changed since cache was written dirInfo, err := os.Stat(dirPath) if err != nil { return nil, false } // Also check entry count as a secondary invalidation signal. // Directory mtime may not update immediately on some filesystems // (e.g., tmpfs), but the entry count will always reflect changes. actualEntries, err := os.ReadDir(dirPath) if err != nil { return nil, false } // Count actual entries (excluding .pad and .git) actualCount := 0 for _, e := range actualEntries { if e.Name() != ".pad" && e.Name() != ".git" { actualCount++ } } // Cache is valid only if BOTH mtime and entry count match if idx.Mtime.Equal(dirInfo.ModTime()) && actualCount == idx.EntryCount { return &idx, true } return nil, false } // cacheIndex writes the directory index to the cache file. func cacheIndex(idx *DirectoryIndex) { cachePath := getCachePath(idx.Path) // Ensure directory exists indicesDir := filepath.Dir(cachePath) if err := os.MkdirAll(indicesDir, 0755); err != nil { // Non-fatal; continue without caching return } data, err := json.Marshal(idx) if err != nil { return } // Write to temp file first, then rename for atomicity tmpPath := cachePath + ".tmp" if err := os.WriteFile(tmpPath, data, 0644); err != nil { return } os.Rename(tmpPath, cachePath) }