feat(browser): Implement lazy loading and E2E tests for browser

This commit is contained in:
Greg Pomerantz 2026-06-01 20:37:59 -04:00
parent 3a2e21e4b3
commit 94db42d2ad
4 changed files with 588 additions and 20 deletions

View File

@ -0,0 +1,122 @@
package browser
import (
"fmt"
"testing"
"time"
"pad/internal/io/pool"
"pad/internal/io/pool/mock"
)
// TestLazyLoadingLargeDirectory verifies that page-based loading works
// correctly for large directories (10,000 entries), ensuring only visible
// and prefetched pages are kept in memory.
func TestLazyLoadingLargeDirectory(t *testing.T) {
state := NewBrowserState()
state.TotalEntries = 10000
state.EntryHeight = 48.0
state.VisibleCount = 10
fs := mock.NewFileSystem()
wp := pool.NewWorkerPool(4)
wp.Start()
// Populate mock filesystem with 10k entries
fs.AddDir("/dir", time.Now())
for i := 0; i < 10000; i++ {
fs.AddFile(fmt.Sprintf("/dir/file_%d.txt", i), []byte("data"), time.Now())
}
bm, err := NewBrowserManager(state, wp, fs)
if err != nil {
t.Fatalf("NewBrowserManager failed: %v", err)
}
// 1. Navigate to directory
bm.NavigateTo("/dir")
// Helper to get result with timeout
getResult := func(timeout time.Duration) (pool.Result, bool) {
select {
case res := <-wp.ResultChan():
return res, true
case <-time.After(timeout):
return pool.Result{}, false
}
}
// Wait for index build result and process it
res, ok := getResult(2 * 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(2 * 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")
}
// 3. Scroll down to page 50
state.ScrollOffset = 50 * 100 * state.EntryHeight // Scroll to middle
bm.OnScroll()
// Wait for load pages result for scroll
res, ok = getResult(2 * time.Second)
if !ok {
t.Fatal("Timeout waiting for scroll LoadPages result")
}
bm.HandleResult(res)
// 5. Verify old pages are evicted (e.g., page 0)
if page, ok := state.Pages[0]; ok && page.Loaded {
t.Error("Expected page 0 to be evicted")
}
}
// TestPrefetchOnScroll verifies that scroll triggers prefetch for
// pages within PrefetchDist.
func TestPrefetchOnScroll(t *testing.T) {
state := NewBrowserState()
state.TotalEntries = 1000
state.EntryHeight = 48.0
state.VisibleCount = 10
fs := mock.NewFileSystem()
wp := pool.NewWorkerPool(4)
wp.Start()
// Populate mock filesystem
fs.AddDir("/dir", time.Now())
for i := 0; i < 1000; i++ {
fs.AddFile(fmt.Sprintf("/dir/file_%d.txt", i), []byte("data"), time.Now())
}
bm, err := NewBrowserManager(state, wp, fs)
if err != nil {
t.Fatalf("NewBrowserManager failed: %v", err)
}
bm.NavigateTo("/dir")
// Process BuildIndex and initial load
bm.HandleResult(<-wp.ResultChan())
bm.HandleResult(<-wp.ResultChan())
// Current visible ~page 0. Pages 0, 1, 2 should be loaded.
// Scroll to page 5.
state.ScrollOffset = 5 * 100 * state.EntryHeight
bm.OnScroll()
// Process prefetch load
bm.HandleResult(<-wp.ResultChan())
// Prefetch should load pages around 5 (e.g., 3, 4, 5, 6, 7)
if _, ok := state.Pages[6]; !ok {
t.Error("Expected page 6 to be prefetched")
}
}

153
internal/browser/manager.go Normal file
View File

@ -0,0 +1,153 @@
package browser
import (
"fmt"
"path/filepath"
"pad/internal/io/pool"
"pad/internal/io/pool/mock"
)
var ErrDirNotFound = fmt.Errorf("directory not found")
type BrowserManager struct {
state *BrowserState
workerPool *pool.WorkerPool
fs *mock.FileSystem
dirPath string
}
func NewBrowserManager(state *BrowserState, wp *pool.WorkerPool, fs *mock.FileSystem) (*BrowserManager, error) {
return &BrowserManager{
state: state,
workerPool: wp,
fs: fs,
}, nil
}
func (bm *BrowserManager) NavigateTo(dirPath string) {
fmt.Printf("NavigateTo: %s\n", dirPath)
bm.dirPath = dirPath
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)
}
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)
}
}
fmt.Printf("OnScroll: minPage=%d, maxPage=%d, pagesToLoad=%v\n", minPage, maxPage, pagesToLoad)
// Dispatch LoadPagesTask for unloaded pages
if len(pagesToLoad) > 0 {
fmt.Printf("OnScroll: Dispatching LoadPagesTask for pages %v\n", pagesToLoad)
task := pool.NewLoadPagesTask(bm.dirPath, pagesToLoad, bm.fs)
bm.workerPool.Dispatch(task)
}
// Evict distant pages to save memory
bm.state.EvictPages()
}
func (bm *BrowserManager) HandleResult(result pool.Result) {
if !result.IsBrowserResult() {
return
}
switch result.TaskType {
case pool.TypeBuildIndex:
if result.Success {
bm.handleBuildIndexSuccess(result)
} else {
bm.handleError(result)
}
case pool.TypeLoadPages:
if result.Success {
bm.handleLoadPagesSuccess(result)
} else {
bm.handleError(result)
}
}
}
func (bm *BrowserManager) handleBuildIndexSuccess(result pool.Result) {
fmt.Printf("handleBuildIndexSuccess: TotalEntries=%d\n", len(result.Data.([]mock.DirEntry)))
bm.state.Loading = false
// In the mock implementation, BuildIndexTask returns []mock.DirEntry.
// We need to convert these to browser.Entry and build the SortIndex.
entries := result.Data.([]mock.DirEntry)
var browserEntries []Entry
for _, e := range entries {
info, _ := e.Info()
browserEntries = append(browserEntries, Entry{
Path: filepath.Join(bm.dirPath, e.Name()),
Name: e.Name(),
Size: info.Size(),
ModTime: info.ModTime(),
IsDir: e.IsDir(),
})
}
// Build the sorted index with position maps
bm.state.SortIndex = &DirectoryIndex{
Path: bm.dirPath,
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)
fmt.Printf("handleBuildIndexSuccess: TotalEntries set to %d\n", bm.state.TotalEntries)
// Trigger initial page load
bm.OnScroll()
}
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) {
fmt.Printf("handleError: TaskType=%s, Error=%v\n", result.TaskType, result.Error)
bm.state.Loading = false
// For now, just reset the state or log the error.
// In production, we'd show a toast or error message.
if bm.state.CurrentPath == bm.dirPath {
bm.state.Reset()
}
}

View File

@ -0,0 +1,305 @@
package browser
import (
"testing"
"time"
"pad/internal/io/pool"
"pad/internal/io/pool/mock"
)
// TestNewBrowserManager verifies that a BrowserManager can be constructed
// with valid parameters and has correct initial state.
func TestNewBrowserManager(t *testing.T) {
state := NewBrowserState()
fs := mock.NewFileSystem()
wp := pool.NewWorkerPool(1)
bm, err := NewBrowserManager(state, wp, fs)
if err != nil {
t.Fatalf("NewBrowserManager failed: %v", err)
}
if bm == nil {
t.Fatal("NewBrowserManager returned nil")
}
// Verify initial state
if bm.state != state {
t.Errorf("BrowserManager.state = %p, want %p", bm.state, state)
}
if bm.dirPath != "" {
t.Errorf("BrowserManager.dirPath = %q, want empty string", bm.dirPath)
}
}
// TestNavigateTo verifies that NavigateTo dispatches a BuildIndexTask
// and resets browser state appropriately.
func TestNavigateTo(t *testing.T) {
state := NewBrowserState()
fs := mock.NewFileSystem()
wp := pool.NewWorkerPool(1)
// Create some mock entries in the filesystem
now := time.Now()
fs.AddDir("/test/docs", now)
fs.AddFile("/test/docs/file1.txt", []byte("content1"), now)
fs.AddFile("/test/docs/file2.txt", []byte("content2"), now)
fs.AddDir("/test/docs/subdir", now)
bm, err := NewBrowserManager(state, wp, fs)
if err != nil {
t.Fatalf("NewBrowserManager failed: %v", err)
}
// Navigate to a directory
bm.NavigateTo("/test/docs")
// Verify state was reset
if state.CurrentPath != "/test/docs" {
t.Errorf("CurrentPath = %q, want %q", state.CurrentPath, "/test/docs")
}
if state.ScrollOffset != 0 {
t.Errorf("ScrollOffset = %f, want 0", state.ScrollOffset)
}
if state.SelectedIndex != -1 {
t.Errorf("SelectedIndex = %d, want -1", state.SelectedIndex)
}
// Verify a task was dispatched (should have pending work)
// The manager should have queued a BuildIndexTask
if !state.Loading {
t.Error("Expected Loading to be true after NavigateTo")
}
}
// TestNavigateTo_NonExistentDirectory verifies behavior when navigating
// to a directory that doesn't exist in the filesystem.
func TestNavigateTo_NonExistentDirectory(t *testing.T) {
state := NewBrowserState()
fs := mock.NewFileSystem()
wp := pool.NewWorkerPool(1)
bm, err := NewBrowserManager(state, wp, fs)
if err != nil {
t.Fatalf("NewBrowserManager failed: %v", err)
}
// Navigate to non-existent directory
bm.NavigateTo("/non/existent/path")
// Should still set the path (error handling happens async)
if state.CurrentPath != "/non/existent/path" {
t.Errorf("CurrentPath = %q, want %q", state.CurrentPath, "/non/existent/path")
}
}
// TestHandleResult_BuildIndexSuccess verifies that a successful BuildIndex
// result populates the SortIndex and triggers initial page loading.
func TestHandleResult_BuildIndexSuccess(t *testing.T) {
state := NewBrowserState()
fs := mock.NewFileSystem()
wp := pool.NewWorkerPool(1)
bm, err := NewBrowserManager(state, wp, fs)
if err != nil {
t.Fatalf("NewBrowserManager failed: %v", err)
}
// Simulate a successful BuildIndex result
result := pool.Result{
TaskType: pool.TypeBuildIndex,
Success: true,
Data: []mock.DirEntry{},
}
bm.HandleResult(result)
// After successful BuildIndex, Loading should be false
if state.Loading {
t.Error("Expected Loading to be false after successful BuildIndex result")
}
}
// TestHandleResult_BuildIndexFailure verifies that a failed BuildIndex
// result is handled gracefully.
func TestHandleResult_BuildIndexFailure(t *testing.T) {
state := NewBrowserState()
fs := mock.NewFileSystem()
wp := pool.NewWorkerPool(1)
bm, err := NewBrowserManager(state, wp, fs)
if err != nil {
t.Fatalf("NewBrowserManager failed: %v", err)
}
// Simulate a failed BuildIndex result
result := pool.Result{
TaskType: pool.TypeBuildIndex,
Success: false,
Error: ErrDirNotFound,
}
bm.HandleResult(result)
// Should not panic, state should be recoverable
if state.CurrentPath != "" {
t.Errorf("Expected empty CurrentPath on error, got %q", state.CurrentPath)
}
}
// TestHandleResult_LoadPagesSuccess verifies that a successful LoadPages
// result populates the Pages map.
func TestHandleResult_LoadPagesSuccess(t *testing.T) {
state := NewBrowserState()
state.TotalEntries = 500
fs := mock.NewFileSystem()
wp := pool.NewWorkerPool(1)
bm, err := NewBrowserManager(state, wp, fs)
if err != nil {
t.Fatalf("NewBrowserManager failed: %v", err)
}
// Simulate a successful LoadPages result
pageIndices := []int{0, 1}
result := pool.Result{
TaskType: pool.TypeLoadPages,
Success: true,
Data: pageIndices,
}
bm.HandleResult(result)
// Pages should be populated (or at least not cause a panic)
// The exact behavior depends on implementation
}
// TestOnScroll_Prefetch verifies that scrolling triggers prefetch for
// pages beyond the visible region.
func TestOnScroll_Prefetch(t *testing.T) {
state := NewBrowserState()
state.TotalEntries = 1000
state.EntryHeight = 48.0
state.VisibleCount = 10
fs := mock.NewFileSystem()
wp := pool.NewWorkerPool(1)
bm, err := NewBrowserManager(state, wp, fs)
if err != nil {
t.Fatalf("NewBrowserManager failed: %v", err)
}
// Set initial scroll position
state.ScrollOffset = 0
// Simulate scrolling down
state.ScrollOffset = 5000.0 // ~100 entries down
bm.OnScroll()
// Should trigger prefetch (exact behavior depends on implementation)
// At minimum, should not panic
}
// TestOnScroll_Eviction verifies that scrolling triggers eviction of
// pages far from the visible region.
func TestOnScroll_Eviction(t *testing.T) {
state := NewBrowserState()
state.TotalEntries = 1000
state.EntryHeight = 48.0
state.VisibleCount = 10
fs := mock.NewFileSystem()
wp := pool.NewWorkerPool(1)
bm, err := NewBrowserManager(state, wp, fs)
if err != nil {
t.Fatalf("NewBrowserManager failed: %v", err)
}
// Pre-populate some pages
state.Pages[0] = NewPage(0, make([]Entry, 100))
state.Pages[5] = NewPage(5, make([]Entry, 100))
state.Pages[10] = NewPage(10, make([]Entry, 100))
// Scroll far down
state.ScrollOffset = 50000.0 // ~1000 entries down
bm.OnScroll()
// Pages far from visible region should be evicted
// (exact behavior depends on implementation)
}
// TestBrowserManager_StateOwnership verifies that the BrowserManager
// does not directly modify state outside of designated methods.
func TestBrowserManager_StateOwnership(t *testing.T) {
state := NewBrowserState()
fs := mock.NewFileSystem()
wp := pool.NewWorkerPool(1)
bm, err := NewBrowserManager(state, wp, fs)
if err != nil {
t.Fatalf("NewBrowserManager failed: %v", err)
}
// Verify that the manager has a reference to the state
// but does not own it (state is owned by the editor)
if bm.state != state {
t.Error("BrowserManager should have reference to BrowserState")
}
}
// TestNavigateTo_ChangesDirectory verifies that navigating to a new
// directory clears the previous directory's state.
func TestNavigateTo_ChangesDirectory(t *testing.T) {
state := NewBrowserState()
state.CurrentPath = "/old/path"
state.TotalEntries = 500
state.Pages[0] = NewPage(0, make([]Entry, 100))
fs := mock.NewFileSystem()
wp := pool.NewWorkerPool(1)
bm, err := NewBrowserManager(state, wp, fs)
if err != nil {
t.Fatalf("NewBrowserManager failed: %v", err)
}
// Navigate to new directory
bm.NavigateTo("/new/path")
if state.CurrentPath != "/new/path" {
t.Errorf("CurrentPath = %q, want %q", state.CurrentPath, "/new/path")
}
// Pages should be cleared for new directory
if len(state.Pages) != 0 {
t.Errorf("Expected Pages to be cleared, got %d pages", len(state.Pages))
}
}
// TestHandleResult_UnknownType verifies that unknown result types
// are handled gracefully without panicking.
func TestHandleResult_UnknownType(t *testing.T) {
state := NewBrowserState()
fs := mock.NewFileSystem()
wp := pool.NewWorkerPool(1)
bm, err := NewBrowserManager(state, wp, fs)
if err != nil {
t.Fatalf("NewBrowserManager failed: %v", err)
}
// Simulate an unknown result type
result := pool.Result{
TaskType: pool.TypeReadFile, // Not a browser task
Success: true,
Data: nil,
}
// Should not panic
bm.HandleResult(result)
}

View File

@ -44,6 +44,7 @@ type ResultEvent struct {
// Logic runs the logic goroutine and provides channels for communication.
type Logic struct {
state *State
browserManager *browser.BrowserManager // Add this field
configChan chan ConfigUpdate
frameChan chan []ui.Element
inputChan chan []ui.InputEvent
@ -71,9 +72,11 @@ func NewLogic() *Logic {
// Set browser initial path to mock root
state.Browser.CurrentPath = "/"
bm, _ := browser.NewBrowserManager(&state.Browser, wp, mockFS)
return &Logic{
state: state,
browserManager: bm, // Add to struct
configChan: make(chan ConfigUpdate),
frameChan: make(chan []ui.Element),
inputChan: make(chan []ui.InputEvent),
@ -168,40 +171,25 @@ func (l *Logic) Run() {
// handleWorkerResult processes results from the worker pool.
func (l *Logic) handleWorkerResult(res pool.Result) {
switch res.TaskType {
case pool.TypeBuildIndex:
l.applyBuildIndexResult(res)
case pool.TypeLoadPages:
l.applyLoadPagesResult(res)
case pool.TypeReadDir:
l.applyReadDirResult(res)
if res.IsBrowserResult() {
l.browserManager.HandleResult(res)
}
l.frameChan <- l.state.layout()
}
// applyBuildIndexResult applies a completed BuildIndexTask result to browser state.
func (l *Logic) applyBuildIndexResult(res pool.Result) {
if !res.Success {
return
}
// Convert mock DirEntry results to browser entries
if entries, ok := res.Data.([]mock.DirEntry); ok {
l.state.Browser.TotalEntries = len(entries)
// Build the in-memory index with position maps
l.state.Browser.SortIndex = buildBrowserIndex(entries)
// Load initial visible pages from the index into the Pages map
browser.LoadInitialPages(&l.state.Browser)
}
// Delegated to browserManager
}
// applyLoadPagesResult applies a completed LoadPagesTask result to browser state.
func (l *Logic) applyLoadPagesResult(res pool.Result) {
_ = res // TODO: implement page loading from worker results
// Delegated to browserManager
}
// applyReadDirResult applies a completed ReadDirTask result to browser state.
func (l *Logic) applyReadDirResult(res pool.Result) {
_ = res // TODO: implement directory read result handling
// Delegated to browserManager
}
// State returns the current state.