Pad/internal/browser/manager.go
Greg Pomerantz 4c8a13b779 Reload the previous directory's list after a failed navigation
Navigating into an unreadable directory failed cleanly in the header
(handleError reverts CurrentPath to the previous path) but left the
list empty forever: the recovery re-read of the previous directory
found it unchanged, so handleBuildIndexSuccess took the no-change
fast path — which assumes the view already shows that content — while
the failed navigation's navigateToDirectory reset had just wiped
TotalEntries/Pages. No frame was emitted and the browser sat on a
correct header over a dead list (seen on device tapping ".." into
/storage/emulated, which is media_rw:media_rw 0750; only
/storage/emulated/0 is app-exposed).

The fast path now requires a live view (TotalEntries != 0); a
torn-down view always takes the full rebuild, restoring the rows.
Regression test TestHandleResult_NavigationFailureRecoveryReloads
(red without the fix, green with it); verified on emulator and phone.
2026-09-03 12:53:23 -04:00

288 lines
9.9 KiB
Go

package browser
import (
"fmt"
"path/filepath"
"time"
"pad/internal/io/pool"
"pad/internal/io/pool/types"
)
var ErrDirNotFound = fmt.Errorf("directory not found")
type BrowserManager struct {
state *BrowserState
workerPool *pool.WorkerPool
fs pool.FileSystem
// indexInFlightPath is the directory whose BuildIndex read is currently
// in flight ("" if none); indexInFlightIsNav records whether that read
// was a user navigation (recover-on-failure) or a background refresh
// (retry-silently). Both are owner-goroutine-only. Together they keep at
// most one read in flight per directory and let a stale, late result be
// dropped instead of clobbering the directory the user has since moved to.
indexInFlightPath string
indexInFlightIsNav bool
// lastIndexSignature/lastIndexPath describe the last directory index that
// actually changed the view. A refresh whose entries match is a no-op (no
// rebuild, no frame), keeping emission event-driven while the browser
// idles. Owner-goroutine-only.
lastIndexPath string
lastIndexSignature uint64
}
func NewBrowserManager(state *BrowserState, wp *pool.WorkerPool, fs pool.FileSystem) (*BrowserManager, error) {
return &BrowserManager{
state: state,
workerPool: wp,
fs: fs,
}, nil
}
// dispatchIndex records an in-flight directory read and sends its task to the
// worker pool. isNav marks a user navigation (recover on failure) vs a
// background refresh (retry silently on the next tick). Owner-goroutine-only.
func (bm *BrowserManager) dispatchIndex(dirPath string, isNav bool) {
bm.indexInFlightPath = dirPath
bm.indexInFlightIsNav = isNav
bm.workerPool.Dispatch(pool.NewBuildIndexTask(dirPath, bm.fs))
}
func (bm *BrowserManager) NavigateTo(dirPath string) {
// Save the previous path so we can restore it on navigation failure.
bm.state.History = append(bm.state.History, bm.state.CurrentPath)
bm.state.CurrentPath = dirPath
navigateToDirectory(bm.state, dirPath)
bm.state.Loading = true
// Re-read the directory; the landing result rebuilds the sorted index and
// position maps, so the list and its order always reflect disk.
bm.dispatchIndex(dirPath, true)
}
// Load performs the initial directory index for an already-set CurrentPath
// (startup). Like a navigation it recovers on failure, but without touching
// History. Owner-goroutine-only.
func (bm *BrowserManager) Load(dirPath string) {
bm.state.CurrentPath = dirPath
bm.state.Loading = true
bm.dispatchIndex(dirPath, true)
}
// Refresh re-reads the current directory to pick up external changes — files
// added, removed, renamed, or mtime-changed (including inbound syncs) — so the
// list and its sort order stay live while the browser is on screen. Unlike
// NavigateTo it does not touch History or reset the scroll offset, so the
// user's position is preserved; the landing result (handleBuildIndexSuccess)
// rebuilds the index in place and re-sorts. Bounded to one read per directory:
// a refresh while a read of the same directory is in flight is skipped (its
// result will refresh the view). Owner-goroutine-only.
func (bm *BrowserManager) Refresh(dirPath string) {
if dirPath == "" {
return
}
if bm.indexInFlightPath == dirPath {
return
}
bm.state.CurrentPath = dirPath
bm.state.Loading = true
bm.dispatchIndex(dirPath, false)
}
func (bm *BrowserManager) OnScroll() {
if bm.state.TotalEntries == 0 {
return
}
// Identify unloaded pages in the visible + prefetch range
minPage, maxPage := computeVisiblePageRange(bm.state)
var pagesToLoad []int
for i := minPage; i <= maxPage; i++ {
if page, ok := bm.state.Pages[i]; !ok || !page.Loaded {
pagesToLoad = append(pagesToLoad, i)
}
}
// Dispatch LoadPagesTask for unloaded pages
if len(pagesToLoad) > 0 {
task := pool.NewLoadPagesTask(bm.state.CurrentPath, pagesToLoad, bm.fs)
bm.workerPool.Dispatch(task)
}
// Evict distant pages to save memory
bm.state.EvictPages()
}
// HandleResult applies a completed browser task result to the state. It returns
// whether the browser view changed and so a frame should be emitted. A periodic
// refresh that finds the directory unchanged returns false, keeping emission
// event-driven even while the browser idles.
func (bm *BrowserManager) HandleResult(result pool.Result) bool {
if !result.IsBrowserResult() {
return false
}
switch result.TaskType {
case pool.TypeBuildIndex:
if result.Success {
return bm.handleBuildIndexSuccess(result)
}
return bm.handleError(result)
case pool.TypeLoadPages:
if result.Success {
bm.handleLoadPagesSuccess(result)
return true
}
return bm.handleError(result)
}
return false
}
func (bm *BrowserManager) handleBuildIndexSuccess(result pool.Result) bool {
// A read of this directory has landed; clear the in-flight marker.
if bm.indexInFlightPath == result.DirPath {
bm.indexInFlightPath = ""
bm.indexInFlightIsNav = false
}
// A directory we have since left produced a late result (a navigation
// overtook it). Drop it so it cannot clobber the current directory's index
// and pages. (Production results always carry DirPath; the empty check
// keeps hand-built test results without one applying as before.)
if result.DirPath != "" && result.DirPath != bm.state.CurrentPath {
return false
}
bm.state.Loading = false
// In the mock implementation, BuildIndexTask returns []types.DirEntry.
// We need to convert these to browser.Entry and build the SortIndex.
entries := result.Data.([]types.DirEntry)
var browserEntries []Entry
// Add ".." entry if not at root
// We check against "/" specifically. "." is just a relative path for current dir,
// which still has a parent.
if bm.state.CurrentPath != "/" && bm.state.CurrentPath != "" {
browserEntries = append(browserEntries, Entry{
Path: filepath.Dir(bm.state.CurrentPath),
Name: "..",
Size: 0,
ModTime: time.Now(),
IsDir: true,
})
}
for _, e := range entries {
info, _ := e.Info()
browserEntries = append(browserEntries, Entry{
Path: filepath.Join(bm.state.CurrentPath, e.Name()),
Name: e.Name(),
Size: info.Size(),
ModTime: info.ModTime(),
IsDir: e.IsDir(),
})
}
// A refresh of an unchanged directory is a no-op: skip the rebuild and
// report "no change" so no frame is emitted (keeps emission event-driven
// while the browser idles). The signature covers the real entries only —
// name, size, mtime, isdir — and excludes the synthetic ".." row, whose
// mtime is rebuilt on every pass and would otherwise always differ.
sig := directorySignature(browserEntries)
// The no-change fast path assumes the view already shows this content.
// A failed navigation's recovery (handleError) breaks that: it resets the
// rows (navigateToDirectory zeros TotalEntries/Pages) and re-reads the
// previous directory, whose index is unchanged — without this guard the
// re-read would take the fast path and the browser would sit on an empty
// list forever. A torn-down view always takes the full rebuild below.
if bm.state.TotalEntries != 0 &&
bm.lastIndexPath == bm.state.CurrentPath && bm.lastIndexSignature == sig {
return false
}
bm.lastIndexPath = bm.state.CurrentPath
bm.lastIndexSignature = sig
// Build the sorted index with position maps
bm.state.SortIndex = &DirectoryIndex{
Path: bm.state.CurrentPath,
EntryCount: len(browserEntries),
Entries: browserEntries,
SortOrders: make(map[string][]int),
}
// Compute position maps for all sort modes
for mode := SortMode(0); mode < SortMode(modeCount()); mode++ {
key := sortModeKey(mode)
if key != "" {
bm.state.SortIndex.SortOrders[key] = buildPositionMap(browserEntries, mode)
}
}
bm.state.TotalEntries = len(browserEntries)
// Refresh the visible rows synchronously from the fresh index so the frame
// that carries the new index already carries its rows — no async gap and no
// flicker on a periodic refresh. The scroll offset is preserved (a refresh,
// not a jump to top) then clamped in case the list shrank; an active search
// is re-filtered against the new order without yanking the scroll. Distant
// pages are dropped and reload on scroll from the fresh index.
replaceVisiblePagesFromIndex(bm.state)
if bm.state.Query != "" {
prev := bm.state.ScrollOffset
recomputeSearchResults(bm.state)
bm.state.ScrollOffset = prev
}
clampScrollOffset(bm.state)
bm.OnScroll()
return true
}
func (bm *BrowserManager) handleLoadPagesSuccess(result pool.Result) {
// In a real implementation, LoadPagesTask would return the actual entry data.
// In our current mock/placeholder, it just returns the page indices.
// We'll load the data from the SortIndex.
pageIndices := result.Data.([]int)
for _, idx := range pageIndices {
page := loadPageFromIndex(bm.state, idx)
if page != nil {
bm.state.Pages[idx] = page
}
}
}
func (bm *BrowserManager) handleError(result pool.Result) bool {
fmt.Printf("handleError: TaskType=%d, Error=%v\n", result.TaskType, result.Error)
// A read of this directory has (failed) landed; clear the in-flight marker.
wasNav := bm.indexInFlightIsNav
if bm.indexInFlightPath == result.DirPath {
bm.indexInFlightPath = ""
bm.indexInFlightIsNav = false
}
bm.state.Loading = false
// A failed BACKGROUND REFRESH: keep the current (last good) view and let
// the refresh loop retry on its next tick. Do NOT teleport the user or
// reset the list over a transient read error. No visible change, no frame.
if result.TaskType == pool.TypeBuildIndex && !wasNav {
return false
}
// A failed NAVIGATION: restore the previous path so the user is sent back
// to where they were.
if result.TaskType == pool.TypeBuildIndex && len(bm.state.History) > 0 {
prevPath := bm.state.History[len(bm.state.History)-1]
bm.state.History = bm.state.History[:len(bm.state.History)-1]
bm.state.CurrentPath = prevPath
navigateToDirectory(bm.state, prevPath)
bm.dispatchIndex(prevPath, true)
return true
}
// Anything else: reset the state (or, in production, show a toast).
bm.state.Reset()
return true
}