diff --git a/doc/architecture.md b/doc/architecture.md index 68bd078..40999d4 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -544,6 +544,23 @@ Only the visible byte range is shaped and drawn each frame: - The browser renders via `ListView`/`ListItem` elements; rows carry `Interaction` handlers that send channel requests (tap) or mutate state 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`) diff --git a/internal/browser/browser.go b/internal/browser/browser.go index 4505e26..142d8f3 100644 --- a/internal/browser/browser.go +++ b/internal/browser/browser.go @@ -1,6 +1,9 @@ package browser import ( + "fmt" + "hash/fnv" + "sort" "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. // Uses position maps to convert sorted indices to raw indices for entry lookup. // Returns nil if the page index is out of bounds. diff --git a/internal/browser/lazy_loading_test.go b/internal/browser/lazy_loading_test.go index 62500ad..d6cec30 100644 --- a/internal/browser/lazy_loading_test.go +++ b/internal/browser/lazy_loading_test.go @@ -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) if !ok { t.Fatal("Timeout waiting for BuildIndex result") } 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) if len(state.Pages) == 0 { t.Error("Expected pages to be loaded") @@ -104,8 +99,7 @@ func TestPrefetchOnScroll(t *testing.T) { } bm.NavigateTo("/dir") - // Process BuildIndex and initial load - bm.HandleResult(<-wp.ResultChan()) + // Process BuildIndex (visible+prefetch pages are populated synchronously). bm.HandleResult(<-wp.ResultChan()) // Current visible ~page 0. Pages 0, 1, 2 should be loaded. diff --git a/internal/browser/manager.go b/internal/browser/manager.go index 7c46438..7955ee2 100644 --- a/internal/browser/manager.go +++ b/internal/browser/manager.go @@ -15,6 +15,20 @@ 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) { @@ -25,6 +39,15 @@ func NewBrowserManager(state *BrowserState, wp *pool.WorkerPool, fs pool.FileSys }, 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) @@ -33,10 +56,38 @@ func (bm *BrowserManager) NavigateTo(dirPath string) { navigateToDirectory(bm.state, dirPath) bm.state.Loading = true - // Dispatch BuildIndexTask to the worker pool. - // In a real app, this would be a task that reads and sorts the directory. - task := pool.NewBuildIndexTask(dirPath, bm.fs) - bm.workerPool.Dispatch(task) + // 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() { @@ -63,28 +114,45 @@ func (bm *BrowserManager) OnScroll() { 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() { - return + return false } switch result.TaskType { case pool.TypeBuildIndex: if result.Success { - bm.handleBuildIndexSuccess(result) - } else { - bm.handleError(result) + return bm.handleBuildIndexSuccess(result) } + return bm.handleError(result) case pool.TypeLoadPages: if result.Success { bm.handleLoadPagesSuccess(result) - } else { - bm.handleError(result) + return true } + 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 // 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 bm.state.SortIndex = &DirectoryIndex{ Path: bm.state.CurrentPath, @@ -134,8 +214,21 @@ func (bm *BrowserManager) handleBuildIndexSuccess(result pool.Result) { 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() + return true } 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) + + // 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 - // If the failed task was a BuildIndex (directory navigation), restore - // the previous path so the user is sent back to where they were. + // 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) - // Re-dispatch the build index for the previous directory so the - // browser renders the old contents again. - task := pool.NewBuildIndexTask(prevPath, bm.fs) - bm.workerPool.Dispatch(task) - return + bm.dispatchIndex(prevPath, true) + return true } - // For other errors, just reset the state or log the error. - // In production, we'd show a toast or error message. + // Anything else: reset the state (or, in production, show a toast). bm.state.Reset() + return true } diff --git a/internal/browser/manager_test.go b/internal/browser/manager_test.go index c220a11..4936380 100644 --- a/internal/browser/manager_test.go +++ b/internal/browser/manager_test.go @@ -1,6 +1,7 @@ package browser import ( + "os" "testing" "time" @@ -9,6 +10,30 @@ import ( "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 // with valid parameters and has correct initial state. func TestNewBrowserManager(t *testing.T) { @@ -121,9 +146,153 @@ func TestHandleResult_BuildIndexSuccess(t *testing.T) { } } -// TestHandleResult_BuildIndexFailure verifies that a failed BuildIndex -// result is handled gracefully. -func TestHandleResult_BuildIndexFailure(t *testing.T) { +// TestHandleResult_BuildIndexRefreshFailure verifies that a failed BACKGROUND +// REFRESH keeps the current (last good) view rather than teleporting the user +// 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() fs := mock.NewFileSystem() wp := pool.NewWorkerPool(1) @@ -133,18 +302,23 @@ func TestHandleResult_BuildIndexFailure(t *testing.T) { 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{ TaskType: pool.TypeBuildIndex, Success: false, Error: ErrDirNotFound, + DirPath: "/missing", } - bm.HandleResult(result) - // Should not panic, state should be recoverable - if state.CurrentPath != "" { - t.Errorf("Expected empty CurrentPath on error, got %q", state.CurrentPath) + // The user is sent back to the previous directory. + if state.CurrentPath != "/" { + t.Errorf("CurrentPath = %q after nav failure, want / (previous dir restored)", state.CurrentPath) } } diff --git a/internal/editor/logic.go b/internal/editor/logic.go index 2387d07..186708f 100644 --- a/internal/editor/logic.go +++ b/internal/editor/logic.go @@ -93,6 +93,12 @@ type Logic struct { done chan struct{} exitWg sync.WaitGroup 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) 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), retryChan: make(chan string, 1), // Buffered channel autosaveChan: make(chan struct{}), + browserRefreshChan: make(chan struct{}), flushSession: make(chan struct{}, 1), inspectChan: make(chan *inspectReq), writeInFlight: make(map[string]int), @@ -260,8 +267,10 @@ func (l *Logic) Run() { l.exitWg.Add(1) defer l.exitWg.Done() - // Dispatch initial directory index build on startup - l.workerPool.Dispatch(pool.NewBuildIndexTask(l.state.Browser.CurrentPath, l.mockFS)) + // Dispatch the initial directory index build on startup, routed through the + // 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 // 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. l.saveTimer = nil 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: // Clipboard content arrived from the main goroutine: insert it // (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 // the rate limit elapsed (tiny JSON file, see session.go). 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) // Relaunch restore (spec §7): land the armed restore scroll AFTER the // 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. 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() { - l.browserManager.HandleResult(res) + emit = l.browserManager.HandleResult(res) } else if res.TaskType == pool.TypeReadFile { if res.Success { if content, ok := res.Data.([]byte); ok { @@ -824,7 +888,9 @@ func (l *Logic) handleWorkerResult(res pool.Result) { l.state.Editor.applySearchResult(res) } } - l.emitFrame() + if emit { + l.emitFrame() + } } // State returns the current state. diff --git a/internal/editor/state.go b/internal/editor/state.go index 1b312f9..b96aacc 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -686,6 +686,12 @@ func HandleBrowserScroll(data any) { func GoToBrowser(data any) { if TheLogic != nil { 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.page = BrowserPage