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.
205 lines
5.4 KiB
Go
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)
|
|
}
|