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.
288 lines
9.9 KiB
Go
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
|
|
}
|