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:
Greg Pomerantz 2026-09-02 18:23:16 -04:00
parent 5e40378072
commit 16740b0de0
7 changed files with 454 additions and 45 deletions

View File

@ -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`)

View File

@ -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.

View File

@ -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.

View File

@ -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) { 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 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
} }

View File

@ -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)
} }
} }

View File

@ -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,7 +888,9 @@ func (l *Logic) handleWorkerResult(res pool.Result) {
l.state.Editor.applySearchResult(res) l.state.Editor.applySearchResult(res)
} }
} }
l.emitFrame() if emit {
l.emitFrame()
}
} }
// State returns the current state. // State returns the current state.

View File

@ -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