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.
215 lines
5.9 KiB
Go
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
|
|
}
|