Pad/internal/browser/index.go
Greg Pomerantz 06b1444207 gofmt: format all remaining files with the go1.27 toolchain
The tree was formatted with an older gofmt; go1.27's gofmt additionally
wants: EOF exactly one newline (no trailing blank lines), imports sorted
alphabetically within a block, mixed-precedence binary expressions
re-spaced for grouping ((a+b)/c), single-field composite literals
un-aligned, adjacent one-line method signatures aligned, and one-line
bodies containing a compound statement expanded. Applied repo-wide
(31 files under internal/); pure formatting, no semantic changes —
build and the full test suite pass.
2026-08-23 10:03:27 -04:00

205 lines
5.4 KiB
Go

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)
}