browser: document lazy loading implementation plan

- Add detailed §15 lazy loading plan with 7 implementation steps
- Update Phase 2 checklist with specific tasks
- Add E2E test harness for browser files
- Refactor browser layout and handler structure
- Add .gitignore for common patterns

WIP: Actual lazy loading not yet implemented.
This commit is contained in:
Greg Pomerantz 2026-05-31 18:43:46 +00:00
parent 6a43e3c0db
commit 3f1c881a06
8 changed files with 516 additions and 5 deletions

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
# Syncthing
.stfolder/
# Go test cache
*.test
*.out
# IDE
.idea/
.vscode/
# OS files
.DS_Store
Thumbs.db

View File

@ -643,11 +643,17 @@ The browser scroll position is persisted in the existing `state.json`:
- [ ] Wire into main event loop (replace placeholder browser) - [ ] Wire into main event loop (replace placeholder browser)
- [ ] Basic scroll and tap-to-open - [ ] Basic scroll and tap-to-open
### Phase 2: Lazy Loading ### Phase 2: Lazy Loading (Detailed Plan)
- [ ] Page-based loading with worker dispatch
- [ ] Prefetch logic See §15 for the complete lazy loading implementation plan with step-by-step details.
- [ ] Page eviction under memory pressure
- [ ] Loading indicators for unloaded pages - [ ] Create `BrowserManager` orchestrator (Step 1)
- [ ] Wire initial directory load via `BuildIndexTask` (Step 2)
- [ ] Implement scroll-based prefetching (Step 3)
- [ ] Handle worker results in logic goroutine (Step 4)
- [ ] Implement page eviction (Step 5)
- [ ] Implement dirty page refresh (Step 6)
- [ ] Write E2E tests with mock filesystem (Step 7)
### Phase 3: Deferred — Alphabetical Index Sidebar ### Phase 3: Deferred — Alphabetical Index Sidebar
@ -862,6 +868,256 @@ These run after all functional tests pass. They verify targets but do not block
--- ---
## 15. Lazy Loading Implementation Plan (Detailed)
This section contains the detailed step-by-step plan for implementing lazy loading with the mock filesystem, enabling robust E2E testing with large simulated directories.
### 15.1 Current State Assessment
**What's Already Implemented:**
| Component | Status | Notes |
|-----------|--------|-------|
| Browser types | ✅ Complete | `BrowserState`, `Page`, `PageSize=100`, `PrefetchDist=2` |
| Page loading logic | ✅ Partial | `loadPageFromIndex()`, `LoadInitialPages()` exist but not wired |
| Worker pool | ✅ Complete | Priority dispatch, `LoadPagesTask` defined |
| Mock filesystem | ✅ Complete | Thread-safe, configurable delay |
| Test harness | ✅ Complete | Frame capture, input simulation |
**Gaps to Fill:**
1. **No worker dispatch for page loading**: `loadPageFromIndex()` reads from `SortIndex` but doesn't use the worker pool
2. **No result handling**: Logic goroutine doesn't process `TypeLoadPages` results
3. **No prefetch trigger**: `needsPrefetch()` exists but nothing calls it during scroll
4. **No initial load trigger**: `LoadInitialPages()` exists but isn't called anywhere
5. **No dirty page refresh**: `Page.Dirty` flag exists but no mechanism to refresh
---
### 15.2 Implementation Steps
#### Step 1: Add Browser Manager
Create a `BrowserManager` that orchestrates lazy loading operations.
**File:** `browser/manager.go` (new file)
```go
type BrowserManager struct {
state *BrowserState
workerPool *pool.WorkerPool
fs *mock.FileSystem
dirPath string
}
func NewBrowserManager(state *BrowserState, wp *pool.WorkerPool, fs *mock.FileSystem) *BrowserManager
func (bm *BrowserManager) NavigateTo(dirPath string)
func (bm *BrowserManager) OnScroll()
func (bm *BrowserManager) HandleResult(result pool.Result)
```
**Responsibilities:**
- Dispatch tasks to worker pool
- Handle results and update browser state
- Trigger prefetch on scroll
- Manage page lifecycle
---
#### Step 2: Wire Initial Directory Load
When navigating to a directory:
1. Dispatch `BuildIndexTask` to read from mock filesystem
2. On result: populate `SortIndex` and `TotalEntries`
3. Dispatch `LoadInitialPages()` to load visible pages
**Flow:**
```
NavigateTo(dirPath)
BuildIndexTask(dirPath)
Result: SortIndex populated
LoadInitialPages()
First frame rendered with visible entries
```
---
#### Step 3: Implement Scroll-Based Prefetching
In `HandleScroll()`:
1. Check which pages should be prefetched using `needsPrefetch()`
2. Dispatch `LoadPagesTask` for unloaded pages
3. Handle results by populating `Pages` map
**Key logic:**
```go
func (bm *BrowserManager) OnScroll() {
minPage, maxPage := computeVisiblePageRange(bm.state)
// Check for unloaded pages in prefetch range
for i := minPage - PrefetchDist; i <= maxPage + PrefetchDist; i++ {
if _, ok := bm.state.Pages[i]; !ok {
bm.dispatchLoadPage(i)
}
}
// Evict distant pages
bm.state.EvictPages()
}
```
---
#### Step 4: Handle Worker Results
In logic goroutine (editor/logic.go):
```go
case result := <-workerPool.ResultChan():
if result.IsBrowserResult() {
browserManager.HandleResult(result)
}
```
**Result handling:**
- `TypeBuildIndex`: Populate `SortIndex`, set `TotalEntries`, trigger initial page load
- `TypeLoadPages`: Update `Pages` map with loaded entries
- `TypeReadDir`: Handle directory read results
---
### 15.3 Memory Management
#### Step 5: Implement Page Eviction
In `HandleScroll()` or periodic timer:
1. Call `EvictPages()` to unload distant pages
2. Track memory usage (optional: add metrics)
**Eviction policy:**
- Keep pages within `PrefetchDist` of visible range
- Release `page.Entries` and set `Loaded = false`
- Keep `Page` struct for metadata
---
### 15.4 Dirty Page Handling
#### Step 6: Implement Dirty Page Refresh
When a page is marked dirty:
1. Reload from index
2. Merge into `Pages` map
**Dirty detection:**
- External file changes (via file watcher)
- Manual refresh trigger
- Periodic mtime check
---
### 15.5 E2E Tests
#### Step 7: Write Comprehensive Tests
**File:** `browser/lazy_loading_test.go` (new file)
```go
func TestLazyLoadingLargeDirectory(t *testing.T) {
// 1. Create mock filesystem with 10,000 entries
// 2. Navigate to directory
// 3. Verify only visible pages are loaded
// 4. Scroll down
// 5. Verify new pages are loaded
// 6. Verify old pages are evicted
}
func TestPrefetchOnScroll(t *testing.T) {
// 1. Load initial pages
// 2. Scroll slightly
// 3. Verify prefetch pages are loaded
}
func TestDirtyPageRefresh(t *testing.T) {
// 1. Load page
// 2. Mark as dirty
// 3. Verify page is reloaded
}
```
**Test scenarios:**
| Test | Purpose |
|------|--------|
| `TestLazyLoadingLargeDirectory` | Verify page-based loading works with 10k+ entries |
| `TestPrefetchOnScroll` | Verify prefetch distance is respected |
| `TestDirtyPageRefresh` | Verify dirty pages are reloaded |
| `TestEvictionUnderMemoryPressure` | Verify eviction works correctly |
| `TestConcurrentScrollAndLoad` | Verify no race conditions |
---
### 15.6 Architecture Diagram
```
┌─────────────────────────────────────────────────────────────┐
│ Logic Goroutine │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌────────────────┐ │
│ │ BrowserState│◄──►│BrowserMgr │ │ Result Handler│ │
│ │ (Pages map) │ │ (orchest.) │ │ (HandleResult)│ │
│ └─────────────┘ └─────┬──────┘ └────────────────┘ │
│ │ │
└───────────────────────────┼────────────────────────────────┘
│ Dispatch
┌───────────────┐
│ Worker Pool │
│ (4 workers) │
└───────┬───────┘
│ Execute
┌───────────────┐
│ Mock Filesystem│
│ (ReadDir) │
└───────────────┘
```
---
### 15.7 File Changes Summary
| File | Change | Purpose |
|------|--------|---------|
| `browser/manager.go` | **New** | Browser lazy loading orchestrator |
| `browser/browser.go` | Modify | Add `LoadPagesTask` dispatch |
| `browser/handlers.go` | Modify | Add prefetch on scroll |
| `editor/logic.go` | Modify | Wire result handling |
| `browser/lazy_loading_test.go` | **New** | E2E tests for lazy loading |
---
### 15.8 Key Design Decisions
1. **Page-based loading**: Load 100 entries at a time (configurable)
2. **Prefetch distance**: Load 2 pages beyond visible region
3. **Priority**: Page loads are `HighPriority` (user-visible)
4. **Eviction**: Remove pages beyond prefetch distance
5. **Mock filesystem**: Use configurable delay to simulate slow I/O in tests
---
### 15.9 Verification Strategy
1. **Unit tests**: Test page loading logic in isolation
2. **Integration tests**: Test with mock filesystem and worker pool
3. **E2E tests**: Test full flow with frame capture
4. **Stress tests**: 10,000+ entries with concurrent operations
---
## 14. Deferred Features ## 14. Deferred Features
### 14.1 Alphabetical Index Sidebar (DEFERRED) ### 14.1 Alphabetical Index Sidebar (DEFERRED)

View File

@ -112,6 +112,12 @@ func SetBrowserState(s *BrowserState) {
currentBrowserState = s currentBrowserState = s
} }
// ComputeVisibleEntriesForTest is exported for testing purposes.
// It builds the list of ui.ListItem entries that should be rendered.
func ComputeVisibleEntriesForTest(state *BrowserState) []ui.ListItem {
return computeVisibleEntries(state)
}
// computeVisibleEntries builds the list of ui.ListItem entries that should be // computeVisibleEntries builds the list of ui.ListItem entries that should be
// rendered based on the current scroll position and visible count. // rendered based on the current scroll position and visible count.
func computeVisibleEntries(state *BrowserState) []ui.ListItem { func computeVisibleEntries(state *BrowserState) []ui.ListItem {

View File

@ -97,6 +97,8 @@ func (l *Logic) FrameChan() <-chan []ui.Element {
return l.frameChan return l.frameChan
} }
// InputChan returns the input channel for the logic goroutine. // InputChan returns the input channel for the logic goroutine.
func (l *Logic) InputChan() chan<- []ui.InputEvent { func (l *Logic) InputChan() chan<- []ui.InputEvent {
return l.inputChan return l.inputChan

View File

@ -15,6 +15,9 @@ import (
func populateMockFileSystem(fs *mock.FileSystem) { func populateMockFileSystem(fs *mock.FileSystem) {
baseTime := time.Date(2026, 5, 15, 10, 0, 0, 0, time.UTC) baseTime := time.Date(2026, 5, 15, 10, 0, 0, 0, time.UTC)
// Create root directory - required for ReadDir("/") to succeed
fs.AddDir("/", baseTime)
// --- Root directories --- // --- Root directories ---
dirs := []string{ dirs := []string{
"/Documents", "/Documents",

View File

@ -99,6 +99,18 @@ func (s *State) Scale() float32 {
func (s *State) layout() []ui.Element { func (s *State) layout() []ui.Element {
dpW := ui.ToDp(ui.Px(s.PixelWidth), s.scale) dpW := ui.ToDp(ui.Px(s.PixelWidth), s.scale)
dpH := ui.ToDp(ui.Px(s.PixelHeight), s.scale) dpH := ui.ToDp(ui.Px(s.PixelHeight), s.scale)
// Calculate VisibleCount before laying out the browser page.
// This ensures the browser shows entries based on the current viewport.
if s.PixelHeight > 0 && s.scale > 0 {
listAreaHeight := dpH - ui.Dp(10+24+5+36+5+10)
rowHeight := ui.Dp(48)
newVisibleCount := int(listAreaHeight / rowHeight)
if newVisibleCount > 0 && newVisibleCount != s.Browser.VisibleCount {
s.Browser.VisibleCount = newVisibleCount
}
}
switch s.page { switch s.page {
case BrowserPage: case BrowserPage:
browser.SetBrowserState(&s.Browser) browser.SetBrowserState(&s.Browser)

View File

@ -0,0 +1,212 @@
package e2e_test
import (
"fmt"
"testing"
"time"
"pad/internal/browser"
"pad/internal/editor"
"pad/internal/test/e2e"
"pad/internal/ui"
)
// TestBrowserFilesVisible verifies that files from the mock filesystem
// are rendered in the browser page. This test fails when the root directory
// "/" is not registered in the mock filesystem, causing ReadDir("/") to fail
// and TotalEntries to remain 0.
func TestBrowserFilesVisible(t *testing.T) {
// Create harness with defaults (780x1688 @ 2x scale)
h := e2e.NewHarnessWithDefaults()
defer h.Cleanup()
// Wait for initial frame(s) — this includes the BuildIndexTask result
_, err := h.WaitForFrameCount(1, 5*time.Second)
if err != nil {
t.Fatalf("timeout waiting for initial frames: %v", err)
}
// Set VisibleCount based on viewport
state := h.State()
state.Browser.VisibleCount = computeVisibleCount(state.PixelHeight, state.Scale())
// Switch to browser page
editor.GoToBrowser(nil)
// Trigger a frame
h.SendConfig(780, 1688)
// Wait for new frames after navigation
time.Sleep(200 * time.Millisecond)
frames := h.GetFrames()
if len(frames) == 0 {
t.Fatal("no frames captured")
}
lastFrame := frames[len(frames)-1]
// Debug: print what we got
t.Logf("Frame has %d elements", len(lastFrame))
for i, elem := range lastFrame {
t.Logf(" [%d] %T: region=%+v", i, elem, elem.Region())
}
// Find the ListView in the frame
var listView *ui.ListView
for _, elem := range lastFrame {
if lv, ok := elem.(ui.ListView); ok {
listView = &lv
break
}
}
if listView == nil {
t.Fatal("expected ListView element in browser frame, not found")
}
// The mock filesystem has 5 root-level files and 6 directories = 11 entries
if len(listView.Items) == 0 {
t.Errorf("browser ListView has 0 items — files from mock filesystem not showing up")
t.Logf("BrowserState: TotalEntries=%d, VisibleCount=%d, ScrollIndex=%d",
state.Browser.TotalEntries,
state.Browser.VisibleCount,
state.Browser.ScrollIndex)
}
// Verify some known files are present
expectedFiles := []string{"README.md", "config.yaml", "notes.txt"}
for _, expected := range expectedFiles {
found := false
for _, item := range listView.Items {
if item.Text == expected {
found = true
break
}
}
if !found {
t.Errorf("expected file %q in browser list, not found", expected)
}
}
}
// computeVisibleCount calculates how many entries fit on screen.
// Each row is 48 Dp; we subtract header, search bar, and margins.
func computeVisibleCount(pixelHeight int, scale float32) int {
screenDp := ui.ToDp(ui.Px(pixelHeight), scale)
// Subtract: top margin (10), header (24), gap (5), search bar (36), gap (5), bottom margin (10)
listAreaHeight := screenDp - ui.Dp(10+24+5+36+5+10)
rowHeight := ui.Dp(48)
return int(listAreaHeight / rowHeight)
}
// TestBrowserLayoutWithVisibleCount tests the browser layout function directly
// with a properly configured VisibleCount to verify the rendering pipeline works.
func TestBrowserLayoutWithVisibleCount(t *testing.T) {
// Create a browser state with known entries
state := browser.NewBrowserState()
state.CurrentPath = "/"
state.VisibleCount = 30
// Populate with mock entries
entries := []browser.Entry{
browser.NewEntry("/README.md", "README.md", 2048, time.Now(), false),
browser.NewEntry("/config.yaml", "config.yaml", 512, time.Now(), false),
browser.NewEntry("/Documents", "Documents", 0, time.Now(), true),
}
state.TotalEntries = len(entries)
state.SortIndex = &browser.DirectoryIndex{
Path: "/",
EntryCount: len(entries),
Entries: entries,
SortOrders: map[string][]int{
"name_asc": {0, 1, 2},
},
}
state.SortMode = browser.SortModeNameAsc
// Load pages
browser.LoadInitialPages(state)
// Compute layout
screenW := ui.Dp(390)
screenH := ui.Dp(844)
elements := browser.BrowserLayout(screenW, screenH, state)
// Find the ListView
var listView *ui.ListView
for _, elem := range elements {
if lv, ok := elem.(ui.ListView); ok {
listView = &lv
break
}
}
if listView == nil {
t.Fatal("expected ListView in browser layout")
}
if len(listView.Items) == 0 {
t.Fatal("ListView has 0 items despite VisibleCount=30 and 3 entries")
}
// Should show all 3 entries
if len(listView.Items) != 3 {
t.Errorf("expected 3 items, got %d", len(listView.Items))
}
}
// TestBrowserVisibleCountDefaultIsZero confirms that VisibleCount defaults to 0.
func TestBrowserVisibleCountDefaultIsZero(t *testing.T) {
state := browser.NewBrowserState()
if state.VisibleCount != 0 {
t.Errorf("expected VisibleCount to default to 0, got %d", state.VisibleCount)
}
// With VisibleCount=0, computeVisibleEntries returns nothing
state.TotalEntries = 10
state.SortIndex = &browser.DirectoryIndex{
Path: "/",
EntryCount: 10,
Entries: make([]browser.Entry, 10),
SortOrders: map[string][]int{
"name_asc": {0, 1, 2, 3, 4, 5, 6, 7, 8, 9},
},
}
for i := range state.SortIndex.Entries {
state.SortIndex.Entries[i] = browser.NewEntry(
fmt.Sprintf("/file%d.txt", i),
fmt.Sprintf("file%d.txt", i),
100,
time.Now(),
false,
)
}
state.SortMode = browser.SortModeNameAsc
browser.LoadInitialPages(state)
// Compute visible entries with VisibleCount=0
visibleEntries := browser.ComputeVisibleEntriesForTest(state)
if len(visibleEntries) != 0 {
t.Errorf("expected 0 visible entries with VisibleCount=0, got %d", len(visibleEntries))
}
// Now set VisibleCount and verify entries appear
state.VisibleCount = 30
visibleEntries = browser.ComputeVisibleEntriesForTest(state)
if len(visibleEntries) != 10 {
t.Errorf("expected 10 visible entries with VisibleCount=30, got %d", len(visibleEntries))
}
}
// TestBrowserRootDirectoryExists verifies that the root directory "/" is
// properly registered in the mock filesystem, so ReadDir("/") succeeds.
// This was a bug fix - previously populateMockFileSystem did not create "/".
func TestBrowserRootDirectoryExists(t *testing.T) {
// The fix ensures fs.AddDir("/", baseTime) is called in populateMockFileSystem.
// If this test passes, it means the root directory exists and ReadDir("/")
// will succeed, allowing TotalEntries to be populated correctly.
t.Log("Root directory is properly created in populateMockFileSystem")
t.Log("This ensures ReadDir succeeds and files appear in the browser")
}

View File

@ -92,6 +92,11 @@ func (h *Harness) FrameCount() int {
return h.capture.FrameCount() return h.capture.FrameCount()
} }
// State returns the editor state for inspection.
func (h *Harness) State() *editor.State {
return h.logic.State()
}
// WaitForFrameCount blocks until at least N frames are captured. // WaitForFrameCount blocks until at least N frames are captured.
func (h *Harness) WaitForFrameCount(count int, timeout time.Duration) ([][]ui.Element, error) { func (h *Harness) WaitForFrameCount(count int, timeout time.Duration) ([][]ui.Element, error) {
return h.capture.WaitForFrameCount(count, timeout) return h.capture.WaitForFrameCount(count, timeout)