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.
This commit is contained in:
parent
5e40378072
commit
16740b0de0
|
|
@ -544,6 +544,23 @@ Only the visible byte range is shaped and drawn each frame:
|
||||||
- The browser renders via `ListView`/`ListItem` elements; rows carry
|
- The browser renders via `ListView`/`ListItem` elements; rows carry
|
||||||
`Interaction` handlers that send channel requests (tap) or mutate state
|
`Interaction` handlers that send channel requests (tap) or mutate state
|
||||||
directly when the handler runs on the owner (scroll).
|
directly when the handler runs on the owner (scroll).
|
||||||
|
- **Live refresh.** While the browser page is on screen, the current directory
|
||||||
|
is re-read on a short timer (≈1 s) so external changes — files added,
|
||||||
|
removed, renamed, or mtime-changed (including inbound syncs) — appear live,
|
||||||
|
re-sorted in the *current* sort mode. Invariant: **a refresh that finds the
|
||||||
|
directory unchanged rebuilds nothing and emits no frame**, so the
|
||||||
|
event-driven emission contract (§2; `TestNoFramesWhileIdle_Browser`) still
|
||||||
|
holds while the browser idles. A landing (re)index is applied in place: the
|
||||||
|
visible+prefetch pages are repopulated synchronously from the fresh index
|
||||||
|
(no async gap / flicker), the scroll offset is preserved then clamped, and an
|
||||||
|
active search is re-filtered without a scroll jump. Bounded to **one read per
|
||||||
|
directory**: a refresh requested while a read of the same directory is in
|
||||||
|
flight coalesces (skips), and a stale result for a directory the user has
|
||||||
|
since left is dropped rather than clobbering the current view. A failed
|
||||||
|
*refresh* keeps the last good view (the next tick retries); a failed
|
||||||
|
*navigation* still recovers to the previous directory. Returning to the
|
||||||
|
browser from the editor triggers one immediate refresh so the list is current
|
||||||
|
on the frame you land back, not one tick later.
|
||||||
|
|
||||||
## 8. Render pipeline (`internal/ui`)
|
## 8. Render pipeline (`internal/ui`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package browser
|
package browser
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"hash/fnv"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -25,6 +28,51 @@ func LoadInitialPages(s *BrowserState) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// 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.
|
// Uses position maps to convert sorted indices to raw indices for entry lookup.
|
||||||
// Returns nil if the page index is out of bounds.
|
// Returns nil if the page index is out of bounds.
|
||||||
|
|
|
||||||
|
|
@ -45,20 +45,15 @@ func TestLazyLoadingLargeDirectory(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for index build result and process it
|
// Wait for index build result and process it. The visible+prefetch pages
|
||||||
|
// are populated synchronously from the fresh index inside HandleResult (no
|
||||||
|
// separate initial LoadPages result is dispatched).
|
||||||
res, ok := getResult(15 * time.Second)
|
res, ok := getResult(15 * time.Second)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("Timeout waiting for BuildIndex result")
|
t.Fatal("Timeout waiting for BuildIndex result")
|
||||||
}
|
}
|
||||||
bm.HandleResult(res)
|
bm.HandleResult(res)
|
||||||
|
|
||||||
// Wait for initial page load result and process it
|
|
||||||
res, ok = getResult(15 * time.Second)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("Timeout waiting for initial LoadPages result")
|
|
||||||
}
|
|
||||||
bm.HandleResult(res)
|
|
||||||
|
|
||||||
// 2. Verify only initial pages loaded (visible + prefetch)
|
// 2. Verify only initial pages loaded (visible + prefetch)
|
||||||
if len(state.Pages) == 0 {
|
if len(state.Pages) == 0 {
|
||||||
t.Error("Expected pages to be loaded")
|
t.Error("Expected pages to be loaded")
|
||||||
|
|
@ -104,8 +99,7 @@ func TestPrefetchOnScroll(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
bm.NavigateTo("/dir")
|
bm.NavigateTo("/dir")
|
||||||
// Process BuildIndex and initial load
|
// Process BuildIndex (visible+prefetch pages are populated synchronously).
|
||||||
bm.HandleResult(<-wp.ResultChan())
|
|
||||||
bm.HandleResult(<-wp.ResultChan())
|
bm.HandleResult(<-wp.ResultChan())
|
||||||
|
|
||||||
// Current visible ~page 0. Pages 0, 1, 2 should be loaded.
|
// Current visible ~page 0. Pages 0, 1, 2 should be loaded.
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,20 @@ type BrowserManager struct {
|
||||||
state *BrowserState
|
state *BrowserState
|
||||||
workerPool *pool.WorkerPool
|
workerPool *pool.WorkerPool
|
||||||
fs pool.FileSystem
|
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) {
|
func NewBrowserManager(state *BrowserState, wp *pool.WorkerPool, fs pool.FileSystem) (*BrowserManager, error) {
|
||||||
|
|
@ -25,6 +39,15 @@ func NewBrowserManager(state *BrowserState, wp *pool.WorkerPool, fs pool.FileSys
|
||||||
}, nil
|
}, 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) {
|
func (bm *BrowserManager) NavigateTo(dirPath string) {
|
||||||
// Save the previous path so we can restore it on navigation failure.
|
// Save the previous path so we can restore it on navigation failure.
|
||||||
bm.state.History = append(bm.state.History, bm.state.CurrentPath)
|
bm.state.History = append(bm.state.History, bm.state.CurrentPath)
|
||||||
|
|
@ -33,10 +56,38 @@ func (bm *BrowserManager) NavigateTo(dirPath string) {
|
||||||
navigateToDirectory(bm.state, dirPath)
|
navigateToDirectory(bm.state, dirPath)
|
||||||
bm.state.Loading = true
|
bm.state.Loading = true
|
||||||
|
|
||||||
// Dispatch BuildIndexTask to the worker pool.
|
// Re-read the directory; the landing result rebuilds the sorted index and
|
||||||
// In a real app, this would be a task that reads and sorts the directory.
|
// position maps, so the list and its order always reflect disk.
|
||||||
task := pool.NewBuildIndexTask(dirPath, bm.fs)
|
bm.dispatchIndex(dirPath, true)
|
||||||
bm.workerPool.Dispatch(task)
|
}
|
||||||
|
|
||||||
|
// 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() {
|
func (bm *BrowserManager) OnScroll() {
|
||||||
|
|
@ -63,28 +114,45 @@ func (bm *BrowserManager) OnScroll() {
|
||||||
bm.state.EvictPages()
|
bm.state.EvictPages()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bm *BrowserManager) HandleResult(result pool.Result) {
|
// 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() {
|
if !result.IsBrowserResult() {
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
switch result.TaskType {
|
switch result.TaskType {
|
||||||
case pool.TypeBuildIndex:
|
case pool.TypeBuildIndex:
|
||||||
if result.Success {
|
if result.Success {
|
||||||
bm.handleBuildIndexSuccess(result)
|
return bm.handleBuildIndexSuccess(result)
|
||||||
} else {
|
|
||||||
bm.handleError(result)
|
|
||||||
}
|
}
|
||||||
|
return bm.handleError(result)
|
||||||
case pool.TypeLoadPages:
|
case pool.TypeLoadPages:
|
||||||
if result.Success {
|
if result.Success {
|
||||||
bm.handleLoadPagesSuccess(result)
|
bm.handleLoadPagesSuccess(result)
|
||||||
} else {
|
return true
|
||||||
bm.handleError(result)
|
|
||||||
}
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bm *BrowserManager) handleBuildIndexSuccess(result pool.Result) {
|
|
||||||
bm.state.Loading = false
|
bm.state.Loading = false
|
||||||
|
|
||||||
// In the mock implementation, BuildIndexTask returns []types.DirEntry.
|
// In the mock implementation, BuildIndexTask returns []types.DirEntry.
|
||||||
|
|
@ -116,6 +184,18 @@ func (bm *BrowserManager) handleBuildIndexSuccess(result pool.Result) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
if 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
|
// Build the sorted index with position maps
|
||||||
bm.state.SortIndex = &DirectoryIndex{
|
bm.state.SortIndex = &DirectoryIndex{
|
||||||
Path: bm.state.CurrentPath,
|
Path: bm.state.CurrentPath,
|
||||||
|
|
@ -134,8 +214,21 @@ func (bm *BrowserManager) handleBuildIndexSuccess(result pool.Result) {
|
||||||
|
|
||||||
bm.state.TotalEntries = len(browserEntries)
|
bm.state.TotalEntries = len(browserEntries)
|
||||||
|
|
||||||
// Trigger initial page load
|
// 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()
|
bm.OnScroll()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bm *BrowserManager) handleLoadPagesSuccess(result pool.Result) {
|
func (bm *BrowserManager) handleLoadPagesSuccess(result pool.Result) {
|
||||||
|
|
@ -152,25 +245,36 @@ func (bm *BrowserManager) handleLoadPagesSuccess(result pool.Result) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bm *BrowserManager) handleError(result pool.Result) {
|
func (bm *BrowserManager) handleError(result pool.Result) bool {
|
||||||
fmt.Printf("handleError: TaskType=%d, Error=%v\n", result.TaskType, result.Error)
|
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
|
bm.state.Loading = false
|
||||||
|
|
||||||
// If the failed task was a BuildIndex (directory navigation), restore
|
// A failed BACKGROUND REFRESH: keep the current (last good) view and let
|
||||||
// the previous path so the user is sent back to where they were.
|
// 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 {
|
if result.TaskType == pool.TypeBuildIndex && len(bm.state.History) > 0 {
|
||||||
prevPath := bm.state.History[len(bm.state.History)-1]
|
prevPath := bm.state.History[len(bm.state.History)-1]
|
||||||
bm.state.History = bm.state.History[:len(bm.state.History)-1]
|
bm.state.History = bm.state.History[:len(bm.state.History)-1]
|
||||||
bm.state.CurrentPath = prevPath
|
bm.state.CurrentPath = prevPath
|
||||||
navigateToDirectory(bm.state, prevPath)
|
navigateToDirectory(bm.state, prevPath)
|
||||||
// Re-dispatch the build index for the previous directory so the
|
bm.dispatchIndex(prevPath, true)
|
||||||
// browser renders the old contents again.
|
return true
|
||||||
task := pool.NewBuildIndexTask(prevPath, bm.fs)
|
|
||||||
bm.workerPool.Dispatch(task)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For other errors, just reset the state or log the error.
|
// Anything else: reset the state (or, in production, show a toast).
|
||||||
// In production, we'd show a toast or error message.
|
|
||||||
bm.state.Reset()
|
bm.state.Reset()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package browser
|
package browser
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -9,6 +10,30 @@ import (
|
||||||
"pad/internal/io/pool/types"
|
"pad/internal/io/pool/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// testDirEntry / testFileInfo are minimal types.DirEntry implementations so a
|
||||||
|
// browser BuildIndex result can be constructed directly (no worker-pool round
|
||||||
|
// trip, which avoids the follow-up LoadPages tasks that a real landing result
|
||||||
|
// triggers via OnScroll).
|
||||||
|
type testDirEntry struct {
|
||||||
|
name string
|
||||||
|
isDir bool
|
||||||
|
size int64
|
||||||
|
mod time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e testDirEntry) Name() string { return e.name }
|
||||||
|
func (e testDirEntry) IsDir() bool { return e.isDir }
|
||||||
|
func (e testDirEntry) Info() (os.FileInfo, error) { return testFileInfo{e}, nil }
|
||||||
|
|
||||||
|
type testFileInfo struct{ e testDirEntry }
|
||||||
|
|
||||||
|
func (f testFileInfo) Name() string { return f.e.name }
|
||||||
|
func (f testFileInfo) Size() int64 { return f.e.size }
|
||||||
|
func (f testFileInfo) Mode() os.FileMode { return 0o644 }
|
||||||
|
func (f testFileInfo) ModTime() time.Time { return f.e.mod }
|
||||||
|
func (f testFileInfo) IsDir() bool { return f.e.isDir }
|
||||||
|
func (f testFileInfo) Sys() any { return nil }
|
||||||
|
|
||||||
// TestNewBrowserManager verifies that a BrowserManager can be constructed
|
// TestNewBrowserManager verifies that a BrowserManager can be constructed
|
||||||
// with valid parameters and has correct initial state.
|
// with valid parameters and has correct initial state.
|
||||||
func TestNewBrowserManager(t *testing.T) {
|
func TestNewBrowserManager(t *testing.T) {
|
||||||
|
|
@ -121,9 +146,153 @@ func TestHandleResult_BuildIndexSuccess(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleResult_BuildIndexFailure verifies that a failed BuildIndex
|
// TestHandleResult_BuildIndexRefreshFailure verifies that a failed BACKGROUND
|
||||||
// result is handled gracefully.
|
// REFRESH keeps the current (last good) view rather than teleporting the user
|
||||||
func TestHandleResult_BuildIndexFailure(t *testing.T) {
|
// or resetting the list over a transient read error.
|
||||||
|
func TestHandleResult_BuildIndexRefreshFailure(t *testing.T) {
|
||||||
|
state := NewBrowserState()
|
||||||
|
state.CurrentPath = "/dir"
|
||||||
|
state.TotalEntries = 5
|
||||||
|
state.Pages[0] = NewPage(0, []Entry{{Name: "a.txt"}})
|
||||||
|
fs := mock.NewFileSystem()
|
||||||
|
wp := pool.NewWorkerPool(1)
|
||||||
|
|
||||||
|
bm, err := NewBrowserManager(state, wp, fs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewBrowserManager failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model an in-flight background refresh of the current directory (as
|
||||||
|
// BrowserManager.Refresh would), then feed it a failed result.
|
||||||
|
bm.indexInFlightPath = "/dir"
|
||||||
|
bm.indexInFlightIsNav = false
|
||||||
|
result := pool.Result{
|
||||||
|
TaskType: pool.TypeBuildIndex,
|
||||||
|
Success: false,
|
||||||
|
Error: ErrDirNotFound,
|
||||||
|
DirPath: "/dir",
|
||||||
|
}
|
||||||
|
|
||||||
|
bm.HandleResult(result)
|
||||||
|
|
||||||
|
// The view must be preserved: same directory, same pages, no reset.
|
||||||
|
if state.CurrentPath != "/dir" {
|
||||||
|
t.Errorf("CurrentPath = %q after refresh failure, want /dir (view must be kept)", state.CurrentPath)
|
||||||
|
}
|
||||||
|
if len(state.Pages) != 1 {
|
||||||
|
t.Errorf("Pages = %d after refresh failure, want 1 (view must be kept)", len(state.Pages))
|
||||||
|
}
|
||||||
|
if bm.indexInFlightPath != "" {
|
||||||
|
t.Errorf("indexInFlightPath = %q after refresh failure, want cleared", bm.indexInFlightPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitBrowserResult is a test helper: read one result from the pool's channel
|
||||||
|
// with a deadline, fatal on timeout.
|
||||||
|
func waitBrowserResult(t *testing.T, wp *pool.WorkerPool) pool.Result {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case res := <-wp.ResultChan():
|
||||||
|
return res
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("timeout waiting for browser result")
|
||||||
|
return pool.Result{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRefresh_PicksUpExternalChanges verifies the periodic-refresh contract:
|
||||||
|
// the initial load reports a change; an unchanged refresh reports NO change
|
||||||
|
// (so no frame is emitted while idle) and preserves the scroll offset; and a
|
||||||
|
// refresh after an external add picks up the new file and re-sorts.
|
||||||
|
func TestRefresh_PicksUpExternalChanges(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fs := mock.NewFileSystem()
|
||||||
|
fs.AddDir("/Notes", time.Now()) // only used by the OnScroll LoadPages, harmless
|
||||||
|
wp := pool.NewWorkerPool(2)
|
||||||
|
wp.Start()
|
||||||
|
defer wp.Stop()
|
||||||
|
|
||||||
|
state := NewBrowserState()
|
||||||
|
state.CurrentPath = "/Notes"
|
||||||
|
bm, err := NewBrowserManager(state, wp, fs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewBrowserManager failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mod := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
buildIndex := func(dirPath string, files ...string) pool.Result {
|
||||||
|
entries := make([]types.DirEntry, 0, len(files))
|
||||||
|
for _, f := range files {
|
||||||
|
entries = append(entries, testDirEntry{name: f, size: 1, mod: mod})
|
||||||
|
}
|
||||||
|
return pool.Result{TaskType: pool.TypeBuildIndex, Success: true, Data: entries, DirPath: dirPath}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load: reports a change.
|
||||||
|
if !bm.HandleResult(buildIndex("/Notes", "a.txt")) {
|
||||||
|
t.Fatal("initial load should report a change")
|
||||||
|
}
|
||||||
|
if state.TotalEntries != 2 { // ".." + a.txt
|
||||||
|
t.Fatalf("TotalEntries = %d after load, want 2", state.TotalEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unchanged refresh: reports NO change and preserves the scroll offset.
|
||||||
|
state.ScrollOffset = 123
|
||||||
|
if bm.HandleResult(buildIndex("/Notes", "a.txt")) {
|
||||||
|
t.Fatal("unchanged refresh should report no change")
|
||||||
|
}
|
||||||
|
if state.ScrollOffset != 123 {
|
||||||
|
t.Errorf("ScrollOffset = %v after no-op refresh, want 123 (preserved)", state.ScrollOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An external add: the next refresh picks it up and re-sorts.
|
||||||
|
if !bm.HandleResult(buildIndex("/Notes", "a.txt", "b.txt")) {
|
||||||
|
t.Fatal("refresh after external add should report a change")
|
||||||
|
}
|
||||||
|
if state.TotalEntries != 3 { // ".." + a.txt + b.txt
|
||||||
|
t.Fatalf("TotalEntries = %d after add, want 3", state.TotalEntries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRefresh_CoalescesInFlight verifies that a refresh requested while a read
|
||||||
|
// of the same directory is in flight does not dispatch a second read.
|
||||||
|
func TestRefresh_CoalescesInFlight(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
fs := mock.NewFileSystem()
|
||||||
|
fs.AddDir("/Notes", time.Now())
|
||||||
|
fs.SetDelay(300 * time.Millisecond) // slow the read so we can observe it
|
||||||
|
wp := pool.NewWorkerPool(2)
|
||||||
|
wp.Start()
|
||||||
|
defer wp.Stop()
|
||||||
|
|
||||||
|
state := NewBrowserState()
|
||||||
|
state.CurrentPath = "/Notes"
|
||||||
|
bm, err := NewBrowserManager(state, wp, fs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewBrowserManager failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bm.Load("/Notes") // dispatches one read; it is in flight for ~300ms
|
||||||
|
if bm.indexInFlightPath != "/Notes" {
|
||||||
|
t.Fatalf("expected an in-flight read of /Notes, got %q", bm.indexInFlightPath)
|
||||||
|
}
|
||||||
|
// A refresh while that read is in flight must coalesce (no second read).
|
||||||
|
bm.Refresh("/Notes")
|
||||||
|
if bm.indexInFlightPath != "/Notes" {
|
||||||
|
t.Fatalf("refresh should not replace the in-flight path, got %q", bm.indexInFlightPath)
|
||||||
|
}
|
||||||
|
// Only ONE result should arrive for the coalesced reads.
|
||||||
|
res := waitBrowserResult(t, wp)
|
||||||
|
bm.HandleResult(res)
|
||||||
|
if bm.indexInFlightPath != "" {
|
||||||
|
t.Fatalf("in-flight marker should be cleared after the result, got %q", bm.indexInFlightPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleResult_BuildIndexNavFailureRestoresPrevious verifies that a failed
|
||||||
|
// NAVIGATION restores the previous directory (the existing recovery behavior)
|
||||||
|
// instead of leaving the user on a directory that could not be read.
|
||||||
|
func TestHandleResult_BuildIndexNavFailureRestoresPrevious(t *testing.T) {
|
||||||
state := NewBrowserState()
|
state := NewBrowserState()
|
||||||
fs := mock.NewFileSystem()
|
fs := mock.NewFileSystem()
|
||||||
wp := pool.NewWorkerPool(1)
|
wp := pool.NewWorkerPool(1)
|
||||||
|
|
@ -133,18 +302,23 @@ func TestHandleResult_BuildIndexFailure(t *testing.T) {
|
||||||
t.Fatalf("NewBrowserManager failed: %v", err)
|
t.Fatalf("NewBrowserManager failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simulate a failed BuildIndex result
|
// Model a navigation from "/" to a directory that then fails to read.
|
||||||
|
state.History = append(state.History, "/")
|
||||||
|
state.CurrentPath = "/missing"
|
||||||
|
bm.indexInFlightPath = "/missing"
|
||||||
|
bm.indexInFlightIsNav = true
|
||||||
|
|
||||||
result := pool.Result{
|
result := pool.Result{
|
||||||
TaskType: pool.TypeBuildIndex,
|
TaskType: pool.TypeBuildIndex,
|
||||||
Success: false,
|
Success: false,
|
||||||
Error: ErrDirNotFound,
|
Error: ErrDirNotFound,
|
||||||
|
DirPath: "/missing",
|
||||||
}
|
}
|
||||||
|
|
||||||
bm.HandleResult(result)
|
bm.HandleResult(result)
|
||||||
|
|
||||||
// Should not panic, state should be recoverable
|
// The user is sent back to the previous directory.
|
||||||
if state.CurrentPath != "" {
|
if state.CurrentPath != "/" {
|
||||||
t.Errorf("Expected empty CurrentPath on error, got %q", state.CurrentPath)
|
t.Errorf("CurrentPath = %q after nav failure, want / (previous dir restored)", state.CurrentPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,12 @@ type Logic struct {
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
exitWg sync.WaitGroup
|
exitWg sync.WaitGroup
|
||||||
saveTimer *time.Timer // auto-save debounce timer; non-nil while pending
|
saveTimer *time.Timer // auto-save debounce timer; non-nil while pending
|
||||||
|
// browserRefresh* keep the visible browser directory fresh while the
|
||||||
|
// browser page is on screen (external adds/removes/renames/mtime changes,
|
||||||
|
// incl. inbound syncs). The timer goroutine only sends a token; the owner
|
||||||
|
// does the re-read. All touched only on the owner goroutine.
|
||||||
|
browserRefreshChan chan struct{} // periodic browser re-read (timer -> owner)
|
||||||
|
browserRefreshTimer *time.Timer // browser refresh timer; non-nil while pending
|
||||||
lastEmit time.Time // time of the last frame emission (profiler cadence)
|
lastEmit time.Time // time of the last frame emission (profiler cadence)
|
||||||
debugCmdC chan string // one-shot debug commands from the cmd-file poller; nil = disabled
|
debugCmdC chan string // one-shot debug commands from the cmd-file poller; nil = disabled
|
||||||
|
|
||||||
|
|
@ -183,6 +189,7 @@ func NewLogic(mfs pool.FileSystem, path string, openfunc func(string)) *Logic {
|
||||||
openFileChan: make(chan string),
|
openFileChan: make(chan string),
|
||||||
retryChan: make(chan string, 1), // Buffered channel
|
retryChan: make(chan string, 1), // Buffered channel
|
||||||
autosaveChan: make(chan struct{}),
|
autosaveChan: make(chan struct{}),
|
||||||
|
browserRefreshChan: make(chan struct{}),
|
||||||
flushSession: make(chan struct{}, 1),
|
flushSession: make(chan struct{}, 1),
|
||||||
inspectChan: make(chan *inspectReq),
|
inspectChan: make(chan *inspectReq),
|
||||||
writeInFlight: make(map[string]int),
|
writeInFlight: make(map[string]int),
|
||||||
|
|
@ -260,8 +267,10 @@ func (l *Logic) Run() {
|
||||||
l.exitWg.Add(1)
|
l.exitWg.Add(1)
|
||||||
defer l.exitWg.Done()
|
defer l.exitWg.Done()
|
||||||
|
|
||||||
// Dispatch initial directory index build on startup
|
// Dispatch the initial directory index build on startup, routed through the
|
||||||
l.workerPool.Dispatch(pool.NewBuildIndexTask(l.state.Browser.CurrentPath, l.mockFS))
|
// manager so the in-flight marker is set (a later refresh of the same
|
||||||
|
// directory coalesces, and a late result is dropped once we navigate).
|
||||||
|
l.browserManager.Load(l.state.Browser.CurrentPath)
|
||||||
|
|
||||||
// Relaunch restoration (spec §7): re-open the last file if a session
|
// Relaunch restoration (spec §7): re-open the last file if a session
|
||||||
// was handed in before Run (BeginRestore). The browser index is still
|
// was handed in before Run (BeginRestore). The browser index is still
|
||||||
|
|
@ -362,6 +371,11 @@ func (l *Logic) Run() {
|
||||||
// the owner snapshots content and dispatches the write.
|
// the owner snapshots content and dispatches the write.
|
||||||
l.saveTimer = nil
|
l.saveTimer = nil
|
||||||
l.requestSave(l.state.Editor.Filename)
|
l.requestSave(l.state.Editor.Filename)
|
||||||
|
case <-l.browserRefreshChan:
|
||||||
|
// Periodic browser-refresh tick. The timer goroutine only sent a
|
||||||
|
// token; the owner re-reads the current directory (bounded) and
|
||||||
|
// re-arms. No-op while the editor is on screen.
|
||||||
|
l.doBrowserRefresh()
|
||||||
case p := <-l.state.pasteChan:
|
case p := <-l.state.pasteChan:
|
||||||
// Clipboard content arrived from the main goroutine: insert it
|
// Clipboard content arrived from the main goroutine: insert it
|
||||||
// (replacing any live selection, per the selection-aware edit rule).
|
// (replacing any live selection, per the selection-aware edit rule).
|
||||||
|
|
@ -411,6 +425,14 @@ func (l *Logic) emitFrame() {
|
||||||
// Relaunch snapshot (spec §7): persist when the state has changed and
|
// Relaunch snapshot (spec §7): persist when the state has changed and
|
||||||
// the rate limit elapsed (tiny JSON file, see session.go).
|
// the rate limit elapsed (tiny JSON file, see session.go).
|
||||||
l.saveSessionIfChanged()
|
l.saveSessionIfChanged()
|
||||||
|
// While the browser is on screen, keep its directory fresh so external
|
||||||
|
// adds/removes/renames/mtime changes (incl. inbound syncs) appear live, in
|
||||||
|
// the current sort order. Arming here covers every entry onto the browser
|
||||||
|
// page (startup, back from the editor, sort toggle) and re-arms after each
|
||||||
|
// refresh tick; the timer goroutine only sends a token.
|
||||||
|
if l.state.page == BrowserPage {
|
||||||
|
l.armBrowserRefresh()
|
||||||
|
}
|
||||||
elems := l.state.layout(l.browserManager)
|
elems := l.state.layout(l.browserManager)
|
||||||
// Relaunch restore (spec §7): land the armed restore scroll AFTER the
|
// Relaunch restore (spec §7): land the armed restore scroll AFTER the
|
||||||
// layout pass above (it refreshed MaxScroll for the current viewport and
|
// layout pass above (it refreshed MaxScroll for the current viewport and
|
||||||
|
|
@ -655,10 +677,52 @@ func (l *Logic) markDirty() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// browserRefreshInterval is how often the visible browser directory is re-read
|
||||||
|
// while the browser page is on screen, so external changes (files added,
|
||||||
|
// removed, renamed, or mtime-changed — including inbound syncs) appear live, in
|
||||||
|
// the current sort order. One directory read per interval is a single ReadDir
|
||||||
|
// plus a per-entry stat; the manager's in-flight marker keeps it to one read at
|
||||||
|
// a time, so a slow read simply coalesces the next tick.
|
||||||
|
const browserRefreshInterval = time.Second
|
||||||
|
|
||||||
|
// doBrowserRefresh runs on the owner goroutine when the refresh timer fires:
|
||||||
|
// re-read the current directory (bounded to one in flight) and re-arm. It is a
|
||||||
|
// no-op while the editor is on screen — there is no list to keep fresh there.
|
||||||
|
func (l *Logic) doBrowserRefresh() {
|
||||||
|
l.browserRefreshTimer = nil
|
||||||
|
if l.state.page != BrowserPage {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.browserManager.Refresh(l.state.Browser.CurrentPath)
|
||||||
|
l.armBrowserRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// armBrowserRefresh starts (or leaves running) the periodic browser-refresh
|
||||||
|
// timer. Idempotent: at most one timer at a time. The timer goroutine only
|
||||||
|
// sends a token on browserRefreshChan; the owner does the re-read. The send is
|
||||||
|
// non-blocking so the timer goroutine can never block, even after the logic
|
||||||
|
// loop has exited (the token is then simply dropped). Owner-goroutine-only.
|
||||||
|
func (l *Logic) armBrowserRefresh() {
|
||||||
|
if l.browserRefreshTimer != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.browserRefreshTimer = time.AfterFunc(browserRefreshInterval, func() {
|
||||||
|
select {
|
||||||
|
case l.browserRefreshChan <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// handleWorkerResult processes results from the worker pool.
|
// handleWorkerResult processes results from the worker pool.
|
||||||
func (l *Logic) handleWorkerResult(res pool.Result) {
|
func (l *Logic) handleWorkerResult(res pool.Result) {
|
||||||
|
// A browser result re-emits only when it actually changed the view; a
|
||||||
|
// periodic refresh of an unchanged directory is dropped here, keeping
|
||||||
|
// emission event-driven while the browser idles. Other results always
|
||||||
|
// re-emit.
|
||||||
|
emit := true
|
||||||
if res.IsBrowserResult() {
|
if res.IsBrowserResult() {
|
||||||
l.browserManager.HandleResult(res)
|
emit = l.browserManager.HandleResult(res)
|
||||||
} else if res.TaskType == pool.TypeReadFile {
|
} else if res.TaskType == pool.TypeReadFile {
|
||||||
if res.Success {
|
if res.Success {
|
||||||
if content, ok := res.Data.([]byte); ok {
|
if content, ok := res.Data.([]byte); ok {
|
||||||
|
|
@ -824,8 +888,10 @@ func (l *Logic) handleWorkerResult(res pool.Result) {
|
||||||
l.state.Editor.applySearchResult(res)
|
l.state.Editor.applySearchResult(res)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if emit {
|
||||||
l.emitFrame()
|
l.emitFrame()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// State returns the current state.
|
// State returns the current state.
|
||||||
func (l *Logic) State() *State {
|
func (l *Logic) State() *State {
|
||||||
|
|
|
||||||
|
|
@ -686,6 +686,12 @@ func HandleBrowserScroll(data any) {
|
||||||
func GoToBrowser(data any) {
|
func GoToBrowser(data any) {
|
||||||
if TheLogic != nil {
|
if TheLogic != nil {
|
||||||
TheLogic.FlushAll()
|
TheLogic.FlushAll()
|
||||||
|
// Re-read the current directory now (don't wait for the first periodic
|
||||||
|
// tick) so the list is current the moment we land back: files may have
|
||||||
|
// been added/removed/renamed, or an inbound sync may have changed them,
|
||||||
|
// while we were in the editor. The periodic refresh keeps it fresh
|
||||||
|
// afterwards (see Logic.armBrowserRefresh).
|
||||||
|
TheLogic.browserManager.Refresh(TheLogic.state.Browser.CurrentPath)
|
||||||
}
|
}
|
||||||
TheState.Editor.findClose()
|
TheState.Editor.findClose()
|
||||||
TheState.page = BrowserPage
|
TheState.page = BrowserPage
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user