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