Pad/internal/browser/browser.go
Greg Pomerantz 16740b0de0 Browser: keep the file list live (re-read on change, re-sort, no idle frames)
The browser list was cached: files added/removed/renamed, or whose mtime
changed, did not show up (and did not re-sort) until the user navigated
away and back in. In particular, opening a file and returning showed a
stale list — a new file created elsewhere only appeared after navigating
up and back down.

The browser now re-reads the current directory while it is on screen:

- A short (~1 s) timer re-reads the visible directory so external changes
  (including inbound syncs) appear live, re-sorted in the current sort
  mode. A landing re-index repopulates the visible+prefetch pages
  synchronously (no flicker), preserves the scroll offset, and re-filters
  an active search without a scroll jump.
- Returning to the browser from the editor triggers one immediate re-read
  so the list is current the moment you land back.
- Bounded to one read per directory: a refresh while a read of the same
  directory is in flight coalesces, and a stale result for a directory the
  user has since left is dropped rather than clobbering the current view.
- A failed background refresh keeps the last good view (the next tick
  retries); a failed navigation still recovers to the previous directory.
- Change detection: a refresh that finds the directory unchanged rebuilds
  nothing and emits no frame, preserving the event-driven emission
  contract (TestNoFramesWhileIdle_Browser) while the browser idles.

Verified on-device: external add while sitting in the browser appears and
re-sorts to the top within ~1 s; an external add+remove made while in the
editor is reflected immediately on return; an external delete drops the
row within ~1 s.
2026-09-02 18:23:16 -04:00

215 lines
5.9 KiB
Go

package browser
import (
"fmt"
"hash/fnv"
"sort"
"strings"
)
// LoadInitialPages loads the pages required for the current viewport from the
// SortIndex into the Pages map. This should be called after the SortIndex is built
// or updated.
func LoadInitialPages(s *BrowserState) {
if s.SortIndex == nil || s.TotalEntries == 0 {
return
}
// Calculate the range of pages to load (visible + prefetch)
minPage, maxPage := computeVisiblePageRange(s)
for i := minPage; i <= maxPage; i++ {
if _, ok := s.Pages[i]; !ok {
page := loadPageFromIndex(s, i)
if page != nil {
s.Pages[i] = page
}
}
}
}
// directorySignature is a cheap, order-independent fingerprint of a directory's
// real entries (name, size, mtime, isdir), used to tell a no-op periodic
// refresh from a real change. The synthetic ".." row is excluded: its mtime is
// rebuilt on every pass and would otherwise make the signature differ each time.
func directorySignature(entries []Entry) uint64 {
names := make([]string, 0, len(entries))
byName := make(map[string]Entry, len(entries))
for _, e := range entries {
if e.Name == ".." {
continue
}
names = append(names, e.Name)
byName[e.Name] = e
}
sort.Strings(names)
h := fnv.New64a()
fmt.Fprintf(h, "%d\n", len(names))
for _, n := range names {
e := byName[n]
fmt.Fprintf(h, "%s|%d|%d|%v\n", n, e.Size, e.ModTime.UnixNano(), e.IsDir)
}
return h.Sum64()
}
// replaceVisiblePagesFromIndex drops the current pages and synchronously
// reloads the visible+prefetch pages from the (possibly new) SortIndex, so the
// frame that carries a fresh index already carries its rows — no async gap and
// no flicker on a periodic refresh. Distant pages are dropped and reload from
// the fresh index on scroll. Called from the owner goroutine when a directory
// (re)index lands (see BrowserManager.handleBuildIndexSuccess).
func replaceVisiblePagesFromIndex(s *BrowserState) {
if s.SortIndex == nil || s.TotalEntries == 0 {
s.Pages = make(map[int]*Page)
return
}
minPage, maxPage := computeVisiblePageRange(s)
fresh := make(map[int]*Page, maxPage-minPage+1)
for i := minPage; i <= maxPage; i++ {
if page := loadPageFromIndex(s, i); page != nil {
fresh[i] = page
}
}
s.Pages = fresh
}
// loadPageFromIndex creates a Page from the sorted index at the given page index.
// Uses position maps to convert sorted indices to raw indices for entry lookup.
// Returns nil if the page index is out of bounds.
func loadPageFromIndex(s *BrowserState, pageIndex int) *Page {
if pageIndex < 0 || pageIndex*PageSize >= s.TotalEntries {
return nil
}
if s.SortIndex == nil || len(s.SortIndex.Entries) == 0 {
return nil
}
start := pageIndex * PageSize
end := start + PageSize
if end > s.TotalEntries {
end = s.TotalEntries
}
// Get the position map for the current sort mode
positionMap := s.getSortedIndices()
if positionMap == nil {
return nil
}
// Build page entries using sorted order
entries := make([]Entry, 0, end-start)
for i := start; i < end; i++ {
rawIdx := positionMap[i]
entries = append(entries, s.SortIndex.Entries[rawIdx])
}
return NewPage(pageIndex, entries)
}
// computeVisiblePageRange returns the min and max page indices that should
// be loaded based on the current scroll position and visible count.
func computeVisiblePageRange(s *BrowserState) (minPage, maxPage int) {
minPage = s.GetScrollIndex()/PageSize - PrefetchDist
if minPage < 0 {
minPage = 0
}
maxPage = (s.GetScrollIndex()+s.VisibleCount)/PageSize + PrefetchDist
return minPage, maxPage
}
// getEntryByIndex returns the entry at the given sorted index, or nil if the page
// containing that entry is not loaded. Uses position maps to convert sorted
// indices to raw indices for correct sort order.
func getEntryByIndex(s *BrowserState, sortedIndex int) (*Entry, bool) {
if sortedIndex < 0 || sortedIndex >= s.TotalEntries {
return nil, false
}
pageIndex := sortedIndex / PageSize
page, ok := s.Pages[pageIndex]
if !ok || !page.Loaded {
return nil, false
}
offset := sortedIndex % PageSize
if offset >= len(page.Entries) {
return nil, false
}
return &page.Entries[offset], true
}
// GetSortedIndices is exported for testing purposes.
func (s *BrowserState) GetSortedIndices() []int {
return s.getSortedIndices()
}
// getSortedIndices returns the position map for the current sort mode.
// Returns nil if no sort index is available.
func (s *BrowserState) getSortedIndices() []int {
if s.SortIndex == nil || s.SortIndex.SortOrders == nil {
return nil
}
key := sortModeKey(s.SortMode)
return s.SortIndex.SortOrders[key]
}
// needsPrefetch determines if a page should be prefetched based on the
// current scroll position and visible count.
// Returns (true, pagesNeeded) if prefetch is needed.
func needsPrefetch(s *BrowserState, pageIndex int) (bool, int) {
minPage, maxPage := computeVisiblePageRange(s)
if pageIndex >= minPage && pageIndex <= maxPage {
if page, ok := s.Pages[pageIndex]; !ok || !page.Loaded {
return true, 1
}
}
return false, 0
}
// navigateToDirectory resets browser state and sets the new current path.
func navigateToDirectory(s *BrowserState, path string) {
s.CurrentPath = path
s.ScrollOffset = 0
s.SelectedIndex = -1
s.Pages = make(map[int]*Page)
s.SearchResults = nil
s.Query = ""
s.TotalEntries = 0
}
// computeTotalPages returns the total number of pages for the current entry count.
func computeTotalPages(s *BrowserState) int {
if s.TotalEntries == 0 {
return 0
}
pages := s.TotalEntries / PageSize
if s.TotalEntries%PageSize != 0 {
pages++
}
return pages
}
// filterEntriesByQuery returns indices of entries matching the query (case-insensitive).
// Returns nil for empty query.
func filterEntriesByQuery(entries []Entry, query string) []int {
if query == "" {
return nil
}
queryLower := strings.ToLower(query)
var results []int
for i, e := range entries {
if strings.Contains(strings.ToLower(e.Name), queryLower) {
results = append(results, i)
}
}
return results
}