Pad/doc/browser_implementation_plan.md

42 KiB
Raw Blame History

Browser Implementation Plan

1. Overview

This plan specifies the implementation of the directory browser for Pad, covering lazy loading, virtualized rendering, alphabetical indexing, and search functionality.

Current State (as of 2026-05-30)

Feature Status Location
Browser page rendering Implemented internal/browser/layout.go:BrowserLayout()
Scroll with virtualization Implemented internal/editor/state.go + ui/element.go:ListView
Search (case-insensitive) Implemented internal/browser/search.go:HandleSearch()
Sort (4 modes only) Implemented internal/browser/sort.go (4 modes, not 6)
Worker pool Implemented internal/io/pool/
Filesystem-backed entries Not started Uses static browserEntries slice
Lazy loading / pagination Partial Page model + eviction implemented; disk I/O not wired
Directory index / caching Implemented internal/browser/index.go:buildIndex()
Alphabetical index sidebar ⏸ Deferred See §14

Design Decisions Summary

Decision Rationale
Lazy loading required Directories with 100k+ entries must not block first paint or exhaust memory
Page-based loading Matches the editor's chunked buffer pattern; predictable memory usage
Pre-sorted index with position maps Index stores raw metadata; pre-computed position maps for each of 4 sort modes; O(1) lookup during frame rendering; no sorting on frame path
Virtualized ListView Only render visible items; matches existing ListView element
Worker-dispatched reads Directory I/O goes through high-priority worker channel; logic stays <16ms
Editor-owned state All browser state is embedded in the editor's state to maintain single-owner pattern

2. Package Structure

internal/browser/
  browser.go        # Main browser logic, state machine, page management
  index.go          # Directory index (lazy-loaded, cached, sorted)
  layout.go         # Browser layout computation (produces []Element)
  handlers.go       # Interaction handlers (tap, scroll, search, alpha index)
  types.go          # Browser-specific types (Entry, Page, etc.)

3. Core Types

3.1 Directory Entry

// Entry represents a single file or directory in the browser.
type Entry struct {
    Path     string    // Relative path from configured directory
    Name     string    // Display name (basename)
    Size     int64     // File size in bytes (0 for directories)
    ModTime  time.Time // Last modification time
    IsDir    bool      // True if this is a directory
    IsFirst  bool      // True if this is the first entry for its letter section
}

3.2 Browser Page (Lazy Load Unit)

// Page represents a chunk of directory entries loaded from disk.
type Page struct {
    Index    int      // Page number (0-based)
    Entries  []Entry  // Entries in this page
    Loaded   bool     // True if page data is in memory
    Dirty    bool     // True if page needs refresh (external change detected)
}

const (
    PageSize     = 100  // Entries per page (tunable; ~11KB per page in memory)
    PrefetchDist = 2    // Pages to prefetch beyond visible region
)

3.3 Browser State

// BrowserState holds all mutable browser state owned by the logic goroutine.
// This struct is embedded in the editor's State to maintain the single-owner pattern.
type BrowserState struct {
    // Navigation
    CurrentPath    string  // Currently browsed directory (relative to root)
    ScrollOffset   float64 // Vertical scroll offset in pixels
    EntryHeight    float64 // Height of a single entry in pixels
    VisibleCount   int     // Number of entries currently visible

    // Lazy loading
    Pages          map[int]*Page  // Loaded pages by page index
    TotalEntries   int            // Total entry count (from cached index)
    Loading        bool           // True if a page load is in flight

    // Search
    Query          string  // Current search query
    SearchResults  []int   // Indices of matching entries (empty = no filter)

    // Alphabetical index (DEFERRED - see §14.1)
    // ActiveLetter   string  // Currently pressed letter (for highlighting)
    // LetterOffsets  map[string]int  // First entry index for each letter

    // Interaction
    SelectedIndex  int     // Currently selected entry (-1 = none)
    TapTimestamp   time.Time // For double-tap detection
}

4. Lazy Loading Architecture

4.1 Page Lifecycle

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   VISIBLE   │────▶│   PREFETCH  │────▶│   EVICTED   │
│  (in memory)│     │  (in memory)│     │ (on disk)   │
└─────────────┘     └─────────────┘     └─────────────┘
      │                    │                   │
      │                    │                   │
      └────────────────────┴───────────────────┘
              scroll triggers transitions

Page states:

  1. Visible: Page is within the current viewport; entries are rendered
  2. Prefetched: Page is within PrefetchDist of the viewport; entries are loaded but not rendered
  3. Evicted: Page is outside prefetch range; entries are released from memory

4.2 Loading Flow

User scrolls browser
    ↓
Logic goroutine: update ScrollIndex, compute visible page range
    ↓
Logic goroutine: identify unloaded pages in [visible - prefetch, visible + prefetch]
    ↓
Logic goroutine: pool.Dispatch(NewLoadPagesTask(dirPath, pageIndices, fs))
    ↓
Worker: Task.Execute() → Result{TaskType: TypeLoadPages, Data: pages}
    ↓
Worker: wp.post(result) — posts to encapsulated result channel
    ↓
Logic goroutine: receives Result via pool.ResultChan(), applies to BrowserState.Pages
    ↓
Logic goroutine: sendFrame() with updated ListView entries

4.3 Memory Management

  • Page eviction: When memory pressure is detected (via the monitoring system in architecture.md §10), pages beyond the prefetch range are evicted
  • Max pages in memory: VisibleCount / PageSize + 2 * PrefetchDist + 2 (visible + prefetch buffer)
  • Per-page memory: ~11KB for 100 entries (Entry struct is ~113 bytes: Path string ~50B, Name string ~30B, Size int64 8B, ModTime time.Time 24B, IsDir/IsFirst bool 2B)
  • Total browser memory budget: ~1.1MB for typical viewport (10 pages)
  • Position maps: 100k × 4 bytes × 4 sort modes = 1.6MB (shared across all pages)

4.4 Eviction Policy

// Evict pages that are far from the current scroll position.
func (s *BrowserState) EvictPages() {
    minPage := s.ScrollIndex/PageSize - PrefetchDist
    maxPage := (s.ScrollIndex + s.VisibleCount)/PageSize + PrefetchDist
    for idx, page := range s.Pages {
        if idx < minPage || idx > maxPage {
            page.Entries = nil  // Release memory
            page.Loaded = false
        }
    }
}

5. Directory Index

5.1 Sorted Index Architecture

The browser uses a pre-sorted index with position maps to deliver entries in the correct sort order without sorting during frame rendering. This ensures the 16ms frame budget is maintained even for directories with 100k+ entries.

Core concept: Instead of storing entries in sorted order (which requires sorting on every frame or mode change), we store entries in raw filesystem order and maintain pre-computed position maps that map sorted indices to raw indices.

┌─────────────────────────────────────────────────────────────┐
│                    SORTED INDEX                             │
├─────────────────────────────────────────────────────────────┤
│  Entries[] (raw filesystem order, unsorted)                  │
│  ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐         │
│  │  0  │  1  │  2  │  3  │  4  │  5  │  6  │  7  │ ...    │
│  └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘         │
│                                                             │
│  Position Maps (one per sort mode, 4 total)                  │
│  ┌─────────────────────────────────────────────────────┐    │
│  │ NameAsc: [2, 5, 0, 7, 3, 1, 6, 4, ...]             │    │
│  │ NameDesc: [4, 6, 1, 3, 7, 0, 5, 2, ...]            │    │
│  │ DateAsc: [5, 0, 7, 2, 3, 1, 6, 4, ...]             │    │
│  │ DateDesc: [4, 6, 1, 3, 7, 0, 5, 2, ...]            │    │
│  └─────────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────┘

Frame path (sub-16ms):

BrowserLayout → computeVisibleEntries → getEntryByIndex(sortedPos, mode)
    ↓
    positionMap = SortOrders[mode]
    rawPos = positionMap[sortedPos]
    return Entries[rawPos]

5.2 Index File Format

The directory index is cached on disk to avoid re-reading and sorting on every browse:

.pad/indices/browser_<dir_hash>.json
{
    "path": "/user/documents",
    "mtime": 1715000000,
    "size": 4096,
    "entry_count": 150000,
    "entries": [
        {"path": "a/file.txt", "name": "a", "size": 0, "mod_time": 1715000000, "is_dir": true},
        ...
    ],
    "sort_orders": {
        "name_asc": [2, 5, 0, 7, 3, 1, 6, 4, ...],
        "name_desc": [4, 6, 1, 3, 7, 0, 5, 2, ...],
        "date_asc": [5, 0, 7, 2, 3, 1, 6, 4, ...],
        "date_desc": [4, 6, 1, 3, 7, 0, 5, 2, ...]
    }
}

Memory calculation for 100k entries:

  • Raw entries: 100k × 113 bytes = 11.3MB
  • Position maps: 100k × 4 bytes × 4 modes = 1.6MB
  • Total: ~13MB (acceptable on modern devices)

5.3 Index Build/Invalidation

Event Action
First browse of directory Build index: read directory, compute position maps, write to .pad/indices/
Directory mtime changed Rebuild index on next browse
External file created/deleted Watcher triggers index rebuild for affected directory
Index file missing Build from scratch

5.4 Index Build Process

// buildIndex reads the directory and produces a cached index with position maps.
func buildIndex(dirPath string) (*DirectoryIndex, error) {
    // 1. Read directory entries (os.ReadDir)
    entries, err := os.ReadDir(dirPath)
    if err != nil {
        return nil, err
    }

    // 2. Convert to Entry structs (unsorted)
    var browserEntries []Entry
    for _, e := range entries {
        info, _ := e.Info()
        browserEntries = append(browserEntries, Entry{
            Path:    filepath.Join(dirPath, e.Name()),
            Name:    e.Name(),
            Size:    info.Size(),
            ModTime: info.ModTime(),
            IsDir:   info.IsDir(),
        })
    }

    // 3. Build position maps for all 4 sort modes
    sortOrders := make(map[SortMode][]int)
    for mode := SortModeNameAsc; mode <= SortModeDateDesc; mode++ {
        sortOrders[mode] = buildPositionMap(browserEntries, mode)
    }

    // 4. Write to cache
    idx := &DirectoryIndex{
        Path:        dirPath,
        Mtime:       dirInfo.ModTime(),
        EntryCount:  len(browserEntries),
        Entries:     browserEntries,
        SortOrders:  sortOrders,
    }
    cacheIndex(idx)

    return idx, nil
}

// buildPositionMap creates a sorted index → raw index mapping.
func buildPositionMap(entries []Entry, mode SortMode) []int {
    n := len(entries)
    indices := make([]int, n)
    for i := range indices {
        indices[i] = i
    }
    
    // Sort indices based on entry comparison
    sort.SliceStable(indices, func(i, j int) bool {
        a, b := entries[indices[i]], entries[indices[j]]
        return comparator(mode)(a, b) < 0
    })
    
    return indices
}

6. Layout Computation

6.1 Browser Layout Function

// BrowserLayout computes []Element for the directory browser page.
// Pure function: (screen dimensions, browser state) → []Element
// This function is in the browser package but called from the editor package.
func BrowserLayout(screenW, screenH ui.Dp, state *BrowserState) []ui.Element {
    // Compute regions
    headerH := ui.Dp(48)
    searchBarH := ui.Dp(40)
    bottomBarH := ui.Dp(24)
    alphaIndexW := ui.Dp(24)

    // Header
    header := ui.NewLabel(
        state.CurrentPath,
        18,
        ui.Region{X: ui.Dp(10), Y: ui.Dp(10), W: screenW - ui.Dp(20), H: headerH},
        ui.AlignStart,
    )

    // Search bar (if query is non-empty or search is active)
    var searchBar ui.Element
    searchY := headerH + ui.Dp(10)
    if state.Query != "" {
        searchBar = ui.NewTextField(ui.Region{
            X: ui.Dp(10), Y: searchY,
            W: screenW - ui.Dp(34), H: searchBarH,
        }, state.Query, "Search...")
        searchY += searchBarH + ui.Dp(4)
    }

    // ListView region
    listW := screenW - alphaIndexW - ui.Dp(20)
    listH := screenH - searchY - bottomBarH - ui.Dp(10)
    listRegion := ui.Region{
        X: ui.Dp(10), Y: searchY,
        W: listW, H: listH,
    }

    // Compute visible entries
    var visibleEntries []ui.ListItem
    startIndex := state.ScrollIndex
    endIndex := startIndex + state.VisibleCount

    // Use search results if filtering
    var effectiveStart, effectiveEnd int
    if len(state.SearchResults) > 0 {
        effectiveStart = state.SearchResults[0]
        effectiveEnd = state.SearchResults[len(state.SearchResults)-1]
    } else {
        effectiveStart = startIndex
        effectiveEnd = endIndex
    }

    // Load pages as needed (sync if available, async if not)
    for idx := effectiveStart; idx < effectiveEnd && idx < state.TotalEntries; idx++ {
        pageIdx := idx / PageSize
        page, loaded := state.Pages[pageIdx]
        if !loaded || !page.Loaded {
            // Show placeholder or skip; page load is in flight
            continue
        }
        entry := page.Entries[idx%PageSize]
        subtext := formatSize(entry.Size)
        if entry.IsDir {
            subtext = "Directory"
        }
        visibleEntries = append(visibleEntries, ui.ListItem{
            Text:     entry.Name,
            Subtext:  subtext,
            Selected: idx == state.SelectedIndex,
        })
    }

    listView := ui.NewListView(listRegion, visibleEntries, state.ScrollIndex, state.SelectedIndex)

    // Alpha index (DEFERRED - see §14.1)
    // alphaIndex := ui.NewAlphaIndex(...)
    
    return []ui.Element{header, searchBar, listView} // alphaIndex deferred
}

7. Interaction Handlers

7.1 Tap Handler (File Selection)

// HandleBrowserTap processes a tap on the ListView.
func HandleBrowserTap(data any) {
    state := editor.TheState.Load()
    state.Lock()
    defer state.Unlock()

    clickData := data.(gesture.ClickEvent)
    // Convert click Y to entry index
    entryIndex := state.Browser.ScrollIndex +
        int(clickData.Location.Y/state.Browser.EntryHeight)

    // Load entry if not in memory
    pageIdx := entryIndex / PageSize
    page, ok := state.Browser.Pages[pageIdx]
    if !ok || !page.Loaded {
        return // Page not loaded yet; will handle on next frame
    }

    entry := page.Entries[entryIndex%PageSize]

    if entry.IsDir {
        // Navigate into directory
        state.Browser.CurrentPath = entry.Path
        state.Browser.ScrollIndex = 0
        state.Browser.Pages = make(map[int]*Page)
        // Dispatch index build/load
        dispatchLoadDirectory(entry.Path)
    } else {
        // Open file in editor
        state.Editor.ActiveFile = entry.Path
        state.Page = PageEditor
        // Dispatch file open
        dispatchOpenFile(entry.Path)
    }
}

7.2 Scroll Handler

// HandleBrowserScroll processes scroll events on the ListView.
func HandleBrowserScroll(data any) {
    state := editor.TheState.Load()
    state.Lock()
    defer state.Unlock()

    scrollData := data.(ScrollEvent)
    state.Browser.ScrollOffset += scrollData.Delta

    // Clamp
    maxScroll := float64(state.Browser.TotalEntries)*state.Browser.EntryHeight - viewHeight
    if state.Browser.ScrollOffset < 0 {
        state.Browser.ScrollOffset = 0
    }
    if state.Browser.ScrollOffset > maxScroll {
        state.Browser.ScrollOffset = maxScroll
    }

    // Trigger prefetch for newly visible pages
    dispatchPrefetchPages(state.Browser)
    state.Browser.EvictPages()
}

7.3 Alphabet Index Handler (DEFERRED)

Status: DEFERRED - The alphabetical index sidebar is not included in this implementation round (see §14.1). The handler would read LetterOffsets and set ScrollIndex directly, but this feature is not being implemented at this time.

7.4 Search Handler

// HandleSearchInput filters entries by the current query.
func HandleSearchInput(data any) {
    state := editor.TheState.Load()
    state.Lock()
    defer state.Unlock()

    query := data.(string)
    state.Browser.Query = query

    if query == "" {
        state.Browser.SearchResults = nil
        return
    }

    // Filter entries (runs over loaded pages; for large directories,
    // this may need to run over the cached index)
    var results []int
    queryLower := strings.ToLower(query)
    for idx := 0; idx < state.Browser.TotalEntries; idx++ {
        pageIdx := idx / PageSize
        page, ok := state.Browser.Pages[pageIdx]
        if !ok || !page.Loaded {
            continue
        }
        entry := page.Entries[idx%PageSize]
        if strings.Contains(strings.ToLower(entry.Name), queryLower) {
            results = append(results, idx)
        }
    }
    state.Browser.SearchResults = results

    // Jump to first result
    if len(results) > 0 {
        state.Browser.ScrollIndex = results[0]
    }
}

8. Worker Tasks

8.1 IO Delegation Model

The browser package follows the architecture's single-owner pattern:

  1. Logic goroutine owns BrowserState — the only goroutine that reads/writes browser state
  2. Logic goroutine dispatches IO tasks via pool.Dispatch() or pool.DispatchNonBlocking()
  3. Workers perform disk IO — read directories, read/write index cache files
  4. Workers call Task.Execute() which returns a generic pool.Result, then post via wp.post(result)
  5. Logic goroutine receives results via pool.ResultChan() in its select loop and applies them to state

Invariant: Workers never access BrowserState directly. They only read from the filesystem and post structured results.

8.2 Result Routing

Browser tasks return generic pool.Result values with TaskType set to one of the browser task types (TypeReadDir, TypeBuildIndex, TypeLoadIndex, TypeLoadPages, TypeStatDir). The logic goroutine uses result.IsBrowserResult() to identify browser results and routes on result.TaskType:

case res := <-wp.ResultChan():
    if res.IsBrowserResult() {
        switch res.TaskType {
        case TypeBuildIndex:
            applyDirectoryLoaded(res)
        case TypeLoadPages:
            applyPagesLoaded(res)
        case TypeReadDir, TypeLoadIndex:
            // Handle as needed
        }
        if res.IsError() {
            applyBrowserError(res)
        }
        sendFrame()
    }

Note: The plan originally described a custom BrowserResult type with fields like Type, DirPath, Data, Err. This was superseded by the generic pool.Result type during worker pool implementation. The generic type avoids per-domain type proliferation — all the information a custom type would carry is already available via Result.TaskType, Task.DirPath(), Result.Data, and Result.Error.

8.3 Task Dispatch

Browser tasks are dispatched through the worker pool via pool.Dispatch() or pool.DispatchNonBlocking(). These methods encapsulate channel access — internal channels are not exposed:

// Dispatch a build index task (blocking — result is required)
pool.Dispatch(pool.NewBuildIndexTask(dirPath, fs))

// Dispatch a page load task (blocking — result is required)
pool.Dispatch(pool.NewLoadPagesTask(dirPath, pageIndices, fs))

Do not use DispatchNonBlocking for browser tasks. If a page load task is dropped, the logic goroutine will wait forever for a result that will never arrive, causing a livelock.

8.4 Task Implementations

Browser tasks implement the pool.Task interface defined in internal/io/pool/task.go:

type Task interface {
    Execute() Result        // Performs the work, returns a Result
    Priority() Priority     // HighPriority or LowPriority
    TaskID() string         // Unique identifier for result matching
    TaskType() TaskType     // Routing key (e.g., TypeBuildIndex)
    DirPath() string        // Directory this task operates on
}

Workers call Task.Execute() which returns a pool.Result. The worker then calls wp.post(result) to send it to the logic goroutine. Tasks never write to channels directly — channel access is encapsulated in the worker pool.

Existing task types in internal/io/pool/task.go: ReadDirTask, BuildIndexTask, LoadIndexTask, LoadPagesTask, StatDirTask. New browser-specific task types can be added there if needed.

8.5 Logic Goroutine Result Handling

The logic goroutine's select loop handles browser results as shown in §8.2. The key invariant is that every dispatched browser task produces exactly one result — the worker pool guarantees this through blocking sends on both the work channel (via Dispatch()) and the result channel (via wp.post()).

8.6 Cache Persistence

The index cache (.pad/indices/) is written by workers during index build, not by the logic goroutine. This means:

  • Index build is entirely async — no blocking on the logic thread
  • Cache reads are entirely async — workers read from disk, post results via wp.post()
  • Logic never touches the filesystem — it only manages in-memory state

This is consistent with the architecture's partitioning: logic stays under 16ms, all IO is delegated.


9. State Persistence

9.1 Browser State in state.json

The browser scroll position is persisted in the existing state.json:

{
    "browser_scroll": 1200,
    "browser_path": "/user/documents",
    "browser_query": ""
}

9.2 Restore Flow

  1. App launches → read state.json
  2. If browser_path exists and is valid → load that directory
  3. Restore browser_scroll position
  4. If directory was deleted → fall back to configured root directory

10. Implementation Phases

Phase 1: Core Browser (MVP)

  • internal/browser/types.go — Define Entry, Page, BrowserState
  • internal/browser/index.go — Directory index build/cache
  • internal/browser/browser.go — State management, page loading
  • internal/browser/layout.go — BrowserLayout function
  • Wire into main event loop (replace placeholder browser)
  • Basic scroll and tap-to-open

Phase 2: Lazy Loading (Detailed Plan)

See §15 for the complete lazy loading implementation plan with step-by-step details.

  • 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

Status: DEFERRED — The alphabetical index sidebar (tap-a-letter to jump) is not included in this implementation round. It can be added later as a standalone feature without changes to the core browser architecture.

Deferred items:

  • Alphabetical index sidebar element
  • Letter offset computation
  • Tap-to-jump functionality
  • Search bar UI
  • Incremental filtering
  • Search result navigation

Phase 5: Polish

  • Scroll momentum (reuse from touch.md §6)
  • External change detection (watcher integration)
  • State persistence and restore
  • Performance testing with large directories

11. Performance Targets

Operation Target Mechanism
First paint (empty directory) < 100 ms Show header + empty list immediately
First paint (100k entries) < 200 ms Load first page async, show loading skeleton
Scroll (60 fps) 16 ms/frame Virtualized ListView, only visible items rendered
Page load < 50 ms Cached index, sequential read
Search (100k entries) < 100 ms Index-level filtering, no disk I/O
Alpha index jump < 10 ms Direct index lookup, no iteration

12. Edge Cases

Scenario Behavior
Directory with 0 files Show empty state message
Directory with 1 file Show single entry, no scroll
Directory with 1M+ entries Lazy load, paginate, evict aggressively
Directory deleted externally Show error, offer to go back or refresh
File renamed externally Index rebuild on next browse
Rapid scroll Debounce page loads; coalesce requests
Memory pressure Evict non-visible pages immediately
Index file corrupted Rebuild from scratch, log warning

13. Testing Strategy — Test-First (TDD)

13.1 Methodology

Rule: Tests are written before any implementation code. Every test must fail before its implementation is written.

This ensures:

  1. Tests are meaningful (they fail without code)
  2. Implementation is minimal (only what's needed to pass)
  3. No accidental pass (test passes without real logic)

Process for each feature:

  1. Write the test file with the expected behavior
  2. Run go test ./internal/browser/... — confirm ALL tests fail
  3. Implement the minimum code to make tests pass
  4. Run tests again — confirm ALL tests pass
  5. Refactor if needed, keeping tests green

13.2 Test File Organization

internal/browser/
  types_test.go        # Tests for Entry, Page, BrowserState constructors
  index_test.go        # Tests for directory index build/cache/invalidation
  browser_test.go      # Tests for page loading, eviction, state management
  layout_test.go       # Tests for BrowserLayout pure function
  scroll_test.go       # Tests for scroll clamping and delta computation
  search_test.go       # Tests for query filtering and result navigation
  fixtures/            # Synthetic directory structures for testing
    empty/             # Empty directory
    small/             # 10 entries
    large/             # 100k+ entries (generated at test time)

13.3 Phase 1 Tests — Types (types_test.go)

Write first. Must all fail.

Test What it verifies Expected failure
TestNewEntryFromFileInfo Entry constructed from fs.DirEntry No Entry struct exists
TestNewPage Page fields initialized correctly Page struct missing
TestPageSizeConstant PageSize is 100 Constant not defined
TestNewBrowserState Default state values (ScrollIndex=0, etc.) BrowserState struct missing

13.4 Phase 1 Tests — Index (index_test.go)

Write after types tests pass. Must all fail.

Test What it verifies Expected failure
TestBuildIndex_EmptyDir Empty directory → 0 entries, no error buildIndex not implemented
TestBuildIndex_SortedByName Entries sorted case-insensitively by name Sort logic missing
TestBuildIndex_DirsBeforeFiles Directories appear before files Sort priority missing
TestBuildIndex_CacheWritten Index written to .pad/indices/ Cache write not implemented
TestBuildIndex_CacheInvalidatedOnMtimeChange Changed mtime triggers rebuild Invalidation logic missing
TestBuildIndex_CacheReusedWhenUnchanged Same mtime → cached index reused Cache read not implemented
TestBuildIndex_LargeDirectory 10k entries built within time budget Not tested at scale

Fixture setup:

func setupTestDir(t *testing.T, name string, entries []testEntry) string {
    dir := t.TempDir()
    // Create files and subdirectories
    return dir
}

13.5 Phase 1 Tests — Browser State (browser_test.go)

Write after index tests pass. Must all fail.

Test What it verifies Expected failure
TestLoadPage_FromIndex Page loaded from index at correct offset Page loading not implemented
TestLoadPage_LastPagePartial Last page has fewer than PageSize entries Boundary handling missing
TestLoadPage_OutOfBounds Requesting page beyond total returns error Bounds check missing
TestEvictPages_RemovesDistantPages Pages outside prefetch range are evicted Eviction not implemented
TestEvictPages_KeepsVisiblePages Visible pages are never evicted Eviction logic incomplete
TestEvictPages_KeepsPrefetchedPages Prefetched pages survive eviction Prefetch boundary missing
TestBrowserState_Reset Reset clears pages, scroll, selection Reset method missing

13.6 Phase 1 Tests — Layout (layout_test.go)

Write after browser tests pass. Must all fail.

These are pure function tests — no mocks needed.

Test What it verifies Expected failure
TestBrowserLayout_ElementCount Correct number of elements emitted BrowserLayout not implemented
TestBrowserLayout_HeaderRegion Header region matches expected position Layout computation missing
TestBrowserLayout_ListRegion ListView region matches expected position Region calculation missing
TestBrowserLayout_VisibleEntriesCount Only visible entries in ListView Virtualization not implemented
TestBrowserLayout_EmptyDirectory Empty state shown when no entries Empty case not handled
TestBrowserLayout_SingleEntry Single entry fills list correctly Boundary case missing
TestBrowserLayout_PartialPage Partial page at end of list Partial page handling missing
TestBrowserLayout_UnloadedPage Unloaded pages show placeholder Placeholder logic missing

13.7 Phase 2 Tests — Scroll (scroll_test.go)

Write after Phase 1 tests pass. Must all fail.

Test What it verifies Expected failure
TestScrollClamp_Minimum ScrollIndex never goes below 0 Clamping not implemented
TestScrollClamp_Maximum ScrollIndex never exceeds max Max calculation missing
TestScrollDelta_Computation Delta computed correctly from pixel offset Delta math missing
TestScroll_PrefetchTriggered Scroll triggers prefetch for new pages Prefetch dispatch missing
TestScroll_EvictionTriggered Scroll triggers eviction of distant pages Eviction on scroll missing

13.8 Phase 2 Tests — Search (search_test.go)

Write after scroll tests pass. Must all fail.

Test What it verifies Expected failure
TestSearch_EmptyQuery Empty query returns all entries Search not implemented
TestSearch_CaseInsensitive "foo" matches "Foo.txt" Case folding missing
TestSearch_PartialMatch "abc" matches "abc.txt" and "xabc.txt" Substring matching missing
TestSearch_NoMatch No matches returns empty results Empty result handling missing
TestSearch_JumpToFirst Scroll jumps to first match Jump logic missing
TestSearch_LargeDirectory Search completes within time budget Performance not verified

13.9 Test Execution Order

Tests must be written and run in this order:

1. types_test.go        → ALL FAIL → implement types → ALL PASS
2. index_test.go        → ALL FAIL → implement index   → ALL PASS
3. browser_test.go      → ALL FAIL → implement browser  → ALL PASS
4. layout_test.go       → ALL FAIL → implement layout   → ALL PASS
5. scroll_test.go       → ALL FAIL → implement scroll   → ALL PASS
6. search_test.go       → ALL FAIL → implement search   → ALL PASS

Verification command at each step:

# Before implementation:
go test ./internal/browser/... -run TestName -v
# Expected: "FAIL" or "panic: not implemented"

# After implementation:
go test ./internal/browser/... -run TestName -v
# Expected: "PASS"

13.10 Performance Tests (Post-Implementation)

These run after all functional tests pass. They verify targets but do not block development.

Test Target Method
BenchmarkBuildIndex_10k < 500 ms testing.B with synthetic dir
BenchmarkBuildIndex_100k < 5 s Same, scaled up
BenchmarkPageLoad < 50 ms Time a single page load from cache
BenchmarkBrowserLayout < 10 ms Time layout computation
BenchmarkSearch_100k < 100 ms Search through 100k entries
BenchmarkEviction < 5 ms Time eviction of 100 pages

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)

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:

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):

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)

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.1 Alphabetical Index Sidebar (DEFERRED)

Not included in this implementation round. The core browser architecture supports adding it later without changes:

  • Letter offsets are computed during index build but not exposed
  • ListView element does not need modification
  • New element AlphaIndex would be added alongside ListView
  • Handler would read LetterOffsets and set ScrollIndex directly

14.2 Future Considerations

Feature Status Notes
Alphabetical index sidebar Deferred See §14.1
Subdirectory navigation Phase 3 Tap directory to navigate in; breadcrumb for back
File type icons Future Small icon prefix in ListView items
Sort order toggle Future Name, date, size; persisted preference
Folder expansion Future Expand/collapse subdirectories inline
Thumbnail preview Out of scope Text editor only; images not in scope