diff --git a/doc/README.md b/doc/README.md
new file mode 100644
index 0000000..3d9d06e
--- /dev/null
+++ b/doc/README.md
@@ -0,0 +1,69 @@
+# Pad documentation
+
+Three documents, kept at the level of *what, why, and invariants* — not
+line-by-line code — so they stay true as the implementation evolves.
+
+| Doc | What it is |
+|---|---|
+| [`spec.md`](./spec.md) | What the app **actually does**, measured performance, the code layout, and an explicit list of deferred features. |
+| [`architecture.md`](./architecture.md) | How it works: single-owner concurrency model, channel topology, Frame handoff contract, ownership rules, editor/browser/render internals. |
+| [`development_plan.md`](./development_plan.md) | The active plan: completed phases, remaining work, and the on-device observation loop. |
+
+## Documentation policy
+
+1. **Docs describe invariants and contracts, not code lines.** If a document
+ has to change on every refactor, it is too detailed — delete it or raise
+ its level of abstraction. Point-in-time implementation plans are deleted
+ once implemented (the previous `*_implementation_plan.md`,
+ `touch.md`, `element_model.md`, `layout_rendering.md`,
+ `virtual_scroll_render_optimization.md`, and `conflict_resolution.md` were
+ removed in the 2026-08 doc reorganization for this reason).
+2. **Unbuilt behavior is not spec'd.** Requirements that are not in the code
+ live in `spec.md` §7 (deferred), never as if they worked.
+3. **Code wins.** When doc and code disagree, fix the doc.
+
+## Build & install (Android, this workstation)
+
+Prereqs on this box (already installed): JDK 17, Go, Android SDK at
+`~/android-sdk` (platform-tools, build-tools 35.0.0, emulator, NDK),
+`gogio@v0.10.0` in `~/go/bin`, `apktool` at `/tmp/apktool.jar`, debug
+keystore at `~/.android/debug.keystore`. Env vars are set in `~/.bashrc`.
+
+The full recipe is `/tmp/build_pad.sh` (machine-local). Steps:
+
+1. `gogio -target android -targetsdk 35 -arch amd64 -o /tmp/pad-raw.apk .`
+ (from `cmd/pad/`)
+2. `apktool d /tmp/pad-raw.apk -o /tmp/pad_decoded -f`
+3. Inject ``
+ into the decoded `AndroidManifest.xml` (Android 15 needs it for
+ `/storage/emulated/0`).
+4. `apktool b /tmp/pad_decoded -o /tmp/pad-unsigned.apk`
+5. `apksigner sign --ks ~/.android/debug.keystore --ks-pass pass:android
+ --key-pass pass:android --ks-key-alias androiddebugkey --out cmd/pad/pad-emu.apk /tmp/pad-unsigned.apk`
+6. `adb install -r cmd/pad/pad-emu.apk`
+
+Run the emulator (headless, AVD `pad_avd`, API 35):
+
+```
+nohup emulator -avd pad_avd -no-window -no-audio -no-boot-anim \
+ -gpu swiftshader_indirect >/tmp/emulator.log 2>&1 &
+```
+
+App package/activity: `pad.pad / org.gioui.GioActivity`. Default root
+directory: `/storage/emulated/0/Notes` (push test files there with
+`adb push file /storage/emulated/0/Notes/`).
+
+## On-device observation loop (quick reference)
+
+Gio renders into one GL surface, so the authoritative debug signals are
+data, not pixels:
+
+- `adb logcat -s pad.pad` — app log (IME commits, open-file timing, errors).
+- `adb shell input tap|swipe|text` — drive the UI (see `development_plan.md`
+ for the tap coordinates and IME-tap cadence that works).
+- Autosave debounce is 1 s: **wait ~1.6 s before reading a file back from
+ disk** after typing.
+- Memory: `adb shell dumpsys meminfo pad.pad` (watch PSS/RSS; the 3.8 GB
+ emulator OOMs the app above ~2.5 GB RSS).
+- Screenshots (`adb exec-out screencap -p > /tmp/x.png`) are an auxiliary
+ check only — state, logcat, and file diffs are authoritative.
diff --git a/doc/architecture.md b/doc/architecture.md
index c73ca1d..63d42ef 100644
--- a/doc/architecture.md
+++ b/doc/architecture.md
@@ -1,505 +1,306 @@
# Runtime Architecture
-This document specifies the concurrency model, goroutine responsibilities, inter-goroutine communication, and the synchronous/asynchronous partitioning of logic operations.
+This document describes how Pad actually works: the concurrency model, goroutine
+responsibilities, the frame handoff contract, ownership rules, and the internal
+design of the editor and browser. It is written at the level of *invariants and
+contracts*, not line-by-line code, so it stays true as implementation details
+evolve. If a detail below conflicts with the code, the code wins — and this
+document should be fixed.
-## 1. Concurrency Model
+## 1. Concurrency model: single owner, no locks on state
-Pad runs three concurrent components coordinated by channels and a single mutex:
+The **logic goroutine is the sole owner (reader and writer) of mutable
+`State`**. There is no `sync.Mutex`/`sync.RWMutex` on application state. Every
+other goroutine talks to the owner through channels.
```
-┌─────────────────────────────────────────────────────────────┐
-│ Main goroutine (Gioui event loop) │
-│ - Waits for app events (w.Event()) │
-│ - On FrameEvent: Lock → Read Frame → Draw → e.Frame → Unlock│
-│ - Batches user input into []InputEvent │
-│ - Sends batch to logic goroutine via inputChan │
-│ - Sends window changes via configChan │
-└─────────────────────────────────────────────────────────────┘
- ↑ w.Invalidate() │ inputChan / configChan
- │ (via Frame Receiver) │ (send)
- │ ↓
-┌─────────────────────────────────────────────────────────────┐
-│ Frame receiver goroutine │
-│ - Tight loop: read frameChan │
-│ - Lock → Store Frame → w.Invalidate() → Unlock │
-└─────────────────────────────────────────────────────────────┘
- ↑ frameChan (read)
- │
-┌─────────────────────────────────────────────────────────────┐
-│ Logic goroutine │
-│ - select on: inputChan, configChan, resultChan │
-│ - Processes input batches: loops through []InputEvent │
-│ - Dispatches work to priority-aware worker pool │
-│ - Sends computed frames on frameChan │
-│ - Guarantees: process + layout < 16 ms │
-└─────────────────────────────────────────────────────────────┘
- ↓ highWorkChan / lowWorkChan (dispatch)
- │
-┌─────────────────────────────────────────────────────────────┐
-│ Worker pool (fixed goroutines) │
-│ - Priority-aware: highWorkChan > lowWorkChan │
-│ - File I/O, diff computation, undo replay on open │
-│ - Results posted on resultChan │
-└─────────────────────────────────────────────────────────────┘
+┌────────────────────────────────────────────────────────────────────┐
+│ Main goroutine (Gio event loop) │
+│ - w.Event() loop: Config / Frame / Destroy │
+│ - On FrameEvent: lock(handoff) → read Frame snapshot → │
+│ Renderer.Draw → collect gestures + key events → │
+│ e.Frame → unlock(handoff) → send inputs/config/query/layout │
+│ to logic channels (sends happen OUTSIDE the lock) │
+└───────────────▲──────────────────────────────┬─────────────────────┘
+ │ handoff lock (frame storage) │ inputChan, configChan,
+ │ + w.Invalidate() │ layoutChan, searchQueryChan
+┌───────────────┴───────────┐ ▼
+│ frameReceiver goroutine │ ┌──────────────────────────────────────┐
+│ tight loop: │ │ Logic goroutine (SOLE OWNER of State)│
+│ f := <-frameChan │ │ - select over all input channels │
+│ lock; *frame = f; │ │ - mutates State, builds []Element │
+│ w.Invalidate(); unlock │ │ - sends Frame on frameChan │
+└───────────────────────────┘ │ - dispatches tasks to worker pool │
+ └───────────────┬──────────────────────┘
+ │ task dispatch / results
+ ┌───────────────▼──────────────────────┐
+ │ Worker pool (8 goroutines) │
+ │ - priority: high > medium > low │
+ │ - file I/O, directory index, line │
+ │ index build, autosave writes │
+ │ - results posted on ResultChan │
+ └──────────────────────────────────────┘
```
-### Key Invariants
+Key invariants:
-1. **The mutex is never held while touching a channel.** All channel sends and receives happen outside the critical section.
-2. **The frame receiver goroutine runs a tight loop.** It reads from `frameChan`, acquires the mutex, stores the frame, calls `w.Invalidate()`, and releases the mutex. This ensures the logic goroutine's send on `frameChan` never blocks unless the receiver is actively storing a frame.
-3. **The main goroutine holds the mutex only during draw + `e.Frame()`.** After releasing the mutex, it sends the batched input to the logic goroutine. This send happens outside the lock.
-4. **The logic goroutine guarantees sub-16ms processing.** It processes input, mutates state, computes layout, and sends a frame — all within the display refresh budget.
+1. **No locks on `State`.** The only mutex is the *handoff* mutex in
+ `cmd/pad/main.go`, which guards the one-frame storage slot between
+ frameReceiver and the main goroutine. It is a message-passing handoff, not
+ a state lock; it is never held while touching a channel.
+2. **The main goroutine never reads logic `State` directly.** It reads only
+ the latest `Frame` snapshot (see §4) and writes only via channels.
+3. **Non-owner goroutines never mutate `State`.** They send a request
+ (channel) or run a callback on the owner (`Inspect`, test-only).
+4. **The autosave timer goroutine reads nothing.** `time.AfterFunc` only sends
+ a token on `autosaveChan`; the owner does all state reads and dispatches
+ the write.
+5. **Gio-mutable widget state lives in the main-goroutine-owned `Renderer`**,
+ never in logic-owned `State` (see §5).
+6. **Logic work is synchronous and fast.** Processing input, mutating state,
+ building the element tree, and sending a frame must stay well under one
+ display refresh (~16 ms). File I/O and index building always go to the
+ worker pool.
-## 2. Goroutine Responsibilities
+## 2. Goroutine responsibilities
-### 2.1 Main Goroutine (Gioui Event Loop)
+### 2.1 Main goroutine (`cmd/pad/main.go`)
-Owns the `*app.Window` and runs the event loop. Responsibilities:
+- Runs the Gio event loop.
+- On `app.ConfigEvent`: sends `ConfigEvent{PixelWidth, PixelHeight}` to
+ `logic.ConfigChan()`.
+- On `app.FrameEvent`:
+ 1. Reads `newScale = gtx.Metric.PxPerDp`; if it differs from
+ `frame.Scale`, sends `ScaleEvent` (after the draw).
+ 2. Under the handoff lock: reads the `Frame` snapshot, calls
+ `renderer.Draw(gtx, frame.Elems, scale)`, then `renderer.CheckGestures`
+ and the focused element's key/edit events (via `key.Filter` +
+ `key.FocusFilter`), then `e.Frame(&ops)`.
+ 3. Outside the lock: sends `[]ui.InputEvent` (if any) to `InputChan`,
+ the search text (if it differs from `frame.Query`) to `SearchQueryChan`,
+ and `renderer.GlyphLayout()` to `LayoutChan`.
+- Owns `frameReceiver` (started in `run`).
-- **Event dispatch**: calls `w.Event()` to receive `app.FrameEvent`, `app.DestroyEvent`, `app.ConfigEvent`
-- **Frame rendering**: on `app.FrameEvent`, acquires the mutex, reads the current frame, draws it via the renderer, calls `e.Frame(&ops)`, releases the mutex
-- **Input batching**: collects all user input (taps, key events, scroll) received during the event cycle into a `[]InputEvent`
-- **Input delivery**: sends the batch to the logic goroutine on `inputChan`
-- **Config delivery**: sends window size or metric changes (`app.ConfigEvent`) to the logic goroutine on `configChan`
+### 2.2 Frame receiver
-### 2.2 Frame Receiver Goroutine
+Tight loop: read `logic.FrameChan()`, store into the shared `Frame` under the
+handoff lock, call `w.Invalidate()`. The logic goroutine's send on `frameChan`
+(bufsize 1) never blocks for more than one frame cycle.
-A dedicated goroutine that bridges the logic goroutine and the main goroutine. Responsibilities:
+### 2.3 Logic goroutine (`internal/editor/logic.go`)
-- **Frame consumption**: reads from `frameChan` in a tight loop
-- **Frame storage**: acquires the mutex, stores the received frame
-- **Frame invalidation**: calls `w.Invalidate()` while holding the mutex to request Gio to invoke a `FrameEvent`
-- **Lock release**: releases the mutex after invalidation
+Sole owner of `*State`. `Run()` selects on:
-This goroutine exists so that the logic goroutine can send frames without blocking. The receiver is always ready to consume, and the mutex hold is minimal.
-
-### 2.3 Logic Goroutine
-
-Owns all application state. Runs a `select` loop over multiple channels. Responsibilities:
-
-- **Input processing**: receives a batch of input events from `inputChan`. It loops through the `[]InputEvent` and updates state accordingly.
-- **Config processing**: receives window/metric changes from `configChan` and updates layout parameters.
-- **Result handling**: receives results from workers on `resultChan`, applies completed async tasks to state.
-- **Work dispatch**: identifies long-running tasks and dispatches them to the worker pool via the appropriate priority channel (`highWorkChan` or `lowWorkChan`).
-- **Frame production**: computes `[]Element` from current state, sends on `frameChan`.
-- **State persistence**: persists state to disk (sync or async, runtime decision — see §5).
-
-The logic goroutine is the sole owner of mutable state. No other goroutine reads or writes state directly.
-
-#### Logic Loop Implementation
-
-The logic goroutine uses a `select` to wait for work:
-
-```go
-for {
- select {
- case inputs := <-inputChan:
- for _, in := range inputs {
- // handle input (e.g., update buffer, move cursor)
- }
- sendFrame()
- case cfg := <-configChan:
- // update window size / metrics
- sendFrame()
- case res := <-resultChan:
- // handle worker result
- sendFrame()
- }
-}
-```
-
-### 2.4 Worker Pool
-
-A fixed set of goroutines (e.g., 4) that execute long-running tasks with two-tier priority.
-
-#### Priority Tiers
-
-1. **High Priority (`highWorkChan`)**: UI-critical operations that block user progress.
- - Loading a file chunk for the cursor/viewport.
- - Loading a directory page in the browser.
- - Computing a diff for an active merge session.
-2. **Low Priority (`lowWorkChan`)**: Background operations that can be delayed without affecting responsiveness.
- - Auto-saving file content.
- - Persisting undo stacks.
- - Building/updating the line index.
-
-#### Implementation
-
-Workers prioritize high-priority tasks using a nested `select`:
-
-```go
-for {
- select {
- case work := <-highWorkChan:
- work.Execute()
- default:
- select {
- case work := <-highWorkChan:
- work.Execute()
- case work := <-lowWorkChan:
- work.Execute()
- }
- }
-}
-```
-
-Workers post results on `resultChan`.
-
-## 3. Channel Topology
-
-| Channel | Direction | Purpose | Blocking behavior |
+| Channel | From | Payload | Action |
|---|---|---|---|
-| `frameChan` | Logic → Frame receiver | Computed frames (`[]Element`) | Unbuffered. Receiver runs tight loop, so logic's send only blocks while receiver holds mutex (fast). |
-| `inputChan` | Main → Logic | Batched user input (`[]InputEvent`) | Unbuffered. Logic drains via `select`, main sends after releasing mutex. |
-| `configChan` | Main → Logic | Window config changes (`app.ConfigEvent`) | Unbuffered. Logic drains via `select`, main sends after releasing mutex. |
-| `resultChan` | Workers → Logic | Completed async task results | Buffered to `workerCount × 4`. Workers post results; logic drains via `select`. Buffer prevents worker blocking when multiple tasks complete simultaneously.
-| `highWorkChan` | Logic → Workers | UI-critical async work | Buffered to `workerCount × 2`. |
-| `lowWorkChan` | Logic → Workers | Background async work | Buffered to `workerCount × 2`. |
+| `configChan` | main | `ConfigUpdate` (pixels or scale) | store scale/pixels; recompute layout |
+| `inputChan` | main | `[]ui.InputEvent` | run each `Handler(evt.Data)` on the owner |
+| `searchQueryChan` | main | `string` | store `Browser.Query`, re-filter, new frame |
+| `openFileChan` | main (tap) | `string` path | open file in editor (chunked buffer + async index) |
+| `layoutChan` | main | `ui.GlyphLayout` | store on editor state; derive `LastLineY` for scroll clamping |
+| `retryChan` | logic | `string` filename | autosave retry |
+| `autosaveChan` | timer | `struct{}` token | reconstruct content, dispatch `WriteFile` task |
+| `workerPool.ResultChan()` | pool | task `Result` | apply async results (stat, read, index, write ack) |
+| `inspectChan` | tests | `inspectReq` | run `fn(*State)` **on the owner** and reply (test-only) |
+| `resultChan` | legacy | `ResultEvent` | drained (legacy channel; the live result path is the pool's) |
+| `done` | main | — | drain pool, stop timer, exit |
-### Why the Channel Design Works
+After every state change the logic goroutine rebuilds the element tree and
+sends a `Frame` on `frameChan`.
-1. **`frameChan`**: The frame receiver goroutine runs a tight loop. It only holds the mutex during the store + invalidate operation. The logic goroutine's send blocks only during this brief window.
+### 2.4 Worker pool (`internal/io/pool`)
-2. **`inputChan` / `configChan`**: The main goroutine sends after releasing the mutex. The logic goroutine processes input in < 16ms. The main goroutine produces at most one batch per frame event. The logic's processing pace exceeds production, so the channel never backs up.
+Fixed 8 workers, two priority lanes (high > low; dispatch routes
+`HighPriority` tasks to the high lane, everything else to low). Tasks are small
+structs with `Execute() Result`; the `FileSystem` interface (`pool.FileSystem`)
+has a real implementation (`io/pool/real`, rooted at `/`) and a mock
+(`io/pool/mock`) for tests.
-3. **`resultChan`**: Buffered to `workerCount × 4`. The worker pool is bounded. Results are posted as workers complete. The logic goroutine drains results in its `select` loop. The buffer prevents workers from blocking when multiple tasks complete simultaneously while logic is processing input.
+Task types in active use: `StatFile`, `ReadFile`, `BuildLineIndex`,
+`WriteFile` (editor); `BuildIndex`, `LoadPages` (browser). Dormant/dead task
+types — `ReadChunk` (no-op for fully-loaded in-range files), `ReadDir`,
+`StatDir`, `ReadCache`, `WriteCache`, `Invalidate`, `SaveState`, `SaveUndo` —
+are candidates for removal in a cleanup round.
-4. **Work Channels (buffered to `workerCount × 2`)**: If all workers are busy, logic's dispatch blocks briefly until a worker frees up. Priority ensures critical tasks jump the queue.
+## 3. Channel topology summary
-## 4. Result Matching
+- **main → logic:** `InputChan`, `ConfigChan`, `LayoutChan`, `SearchQueryChan`,
+ `OpenFileChan`.
+- **timer → logic:** `autosaveChan` (token only).
+- **logic → logic:** `retryChan` (self-piped).
+- **logic → frameReceiver:** `FrameChan` (the only outbound state carrier).
+- **pool → logic:** `WorkerPool.ResultChan()`.
+- **tests → logic:** `Inspect` (owner-executed callback; the production
+ equivalent is "send a request channel message").
-When multiple tasks are dispatched concurrently, the logic goroutine must match results to their originating tasks. Each `Task` carries identifying context:
+## 4. The Frame handoff contract
-| Field | Purpose | Example |
-|---|---|---|
-| `TaskID()` | Unique identifier per dispatch | `"read_dir_/docs_42"` |
-| `TaskType()` | Routing key for result handler | `TypeReadDir`, `TypeLoadPages` |
-| `DirPath()` | Directory the task operates on | `"/user/documents"` |
-
-The `Result` struct includes `TaskID`, `TaskType`, and `Data`. The logic goroutine routes results by `TaskType` (e.g., `IsBrowserResult()`, `IsFileResult()`) and uses `DirPath()` to apply results to the correct state (e.g., loading pages for the currently browsed directory).
+`editor.Frame` is the **only** data that crosses from the logic side to the
+main side:
```go
-case res := <-wp.ResultChan():
- if res.IsBrowserResult() {
- switch res.TaskType {
- case TypeBuildIndex:
- applyDirectoryLoaded(res) // Uses res.Data + DirPath context
- case TypeLoadPages:
- applyPagesLoaded(res) // Matches pages to current directory
- }
- }
-```
-
-Tasks carry their context (directory path, file path, page indices) in their struct fields. When a task executes, it embeds this context in the `Result.Data` field, so the logic goroutine can apply the result to the correct state.
-
-## 5. Error Handling Policy
-
-Worker tasks may fail due to I/O errors, permission issues, or missing files. The error handling policy ensures the editor remains responsive:
-
-| Task Category | On Error | Recovery |
-|---|---|---|
-| **Browser tasks** (read dir, build index, load pages) | Log warning; show placeholder in UI | Retry on next browse; user can navigate away and back |
-| **File tasks** (read file, write file) | Log warning; show toast notification | Auto-save retries on next debounce cycle |
-| **State persistence** (save state, save undo) | Log warning; continue editing | State is re-persisted on next edit |
-| **Cache tasks** (read/write cache) | Log warning; fall back to rebuild | Cache is rebuilt on next access |
-
-**Implementation**: Each task returns a `Result` with `Success: false` and `Error` set. The logic goroutine checks `result.IsError()` and handles accordingly. Errors are never re-queued automatically — the caller decides whether to retry based on the task type.
-
-**User notification**: Critical errors (file read/write failures) show a brief toast. Non-critical errors (cache misses, index rebuilds) are logged silently.
-
-## 6. Timeout and Cancellation
-
-Worker tasks operate under time constraints to prevent the pool from stalling:
-
-### Task Timeouts
-
-Tasks have implicit timeouts enforced by the worker pool:
-
-| Task Type | Timeout | Rationale |
-|---|---|---|
-| High priority (UI-critical) | 5 seconds | User is waiting; must complete or fail fast |
-| Low priority (background) | 30 seconds | Can be retried; no user blockage |
-
-If a task exceeds its timeout, the worker cancels the task's `context.Context` and posts a `Result` with `Error: ErrTimeout`.
-
-### Task Cancellation
-
-Tasks carry a `context.Context` that is cancelled when:
-
-1. **User navigates away**: If the user leaves a directory, pending `LoadPages` tasks for that directory are cancelled.
-2. **File is closed**: Pending chunk loads for a closed file are cancelled.
-3. **Pool shutdown**: `Stop()` closes work channels; workers drain remaining tasks but new dispatches are rejected.
-
-```go
-// Task interface includes context for cancellation
-func (t *ReadDirTask) Execute() Result {
- ctx := t.Context() // Cancelled if user navigates away
- entries, err := t.FS.ReadDirContext(ctx, t.Dir)
- // ...
+type Frame struct {
+ Elems []ui.Element // element tree for the next draw
+ Scale float32 // current px-per-Dp
+ FocusedElementID string // which registered element gets key/edit events
+ Query string // search text logic is filtering with
}
```
-### Timeout Implementation
+- `Elems` is the computed tree; the renderer draws it (it is a snapshot, safe
+ to read from main).
+- `Scale` lets main detect a density change and pass scale to `Renderer.Draw`.
+- `FocusedElementID` drives which registered `key.Filter`/`key.FocusFilter`
+ main uses to harvest keyboard/IME events.
+- `Query` lets main compare against the (main-owned) search `widget.Editor`
+ text and forward changes — the browser search box is a Gio widget, so its
+ text lives on the main side, not in `State`.
-The `execute()` method wraps task execution with a timeout:
+## 5. Ownership rules
-```go
-func (wp *WorkerPool) execute(task Task) Result {
- ctx, cancel := context.WithTimeout(context.Background(), task.Timeout())
- defer cancel()
-
- // Task receives context via Task.Context() or sets it before Execute()
- result := task.Execute()
- result.Timestamp = time.Now()
- return result
-}
-```
-
-## 7. Synchronous/Asynchronous Partitioning
-
-Every user-facing action has a **synchronous state decision** (what the UI shows) and an optional **asynchronous persistence** (writing to disk). The synchronous path must complete in < 16ms.
-
-### 4.1 Text Editing
-
-| Step | Path | Operation |
-|---|---|---|
-| User types 'a' | Sync | Insert into in-memory buffer, append to undo chain, advance cursor, compute visible lines, produce frame |
-| After frame | Async | Debounce timer (1s) triggers file write via worker |
-| After frame | Async | Undo stack persistence (sync or async, runtime decision — §5) |
-
-### 4.2 Backspace
-
-| Step | Path | Operation |
-|---|---|---|
-| User presses backspace | Sync | Check if last undo entry matches; if so pop it, else start delete chain. Move cursor backward, recompute visible lines, produce frame |
-| After frame | Async | Same as text editing (debounced file write, undo persist) |
-
-### 4.3 Undo
-
-| Step | Path | Operation |
-|---|---|---|
-| User triggers undo | Sync | Pop top chain from in-memory stack, re-anchor using context search in in-memory buffer, replay in reverse, update cursor, produce frame |
-| After frame | Async | Persist modified buffer (debounced), persist trimmed undo stack |
-
-### 4.4 Open File
-
-| Step | Path | Operation |
-|---|---|---|
-| User taps file in browser | Sync | Switch page to editor, set `active_file`, show loading state, produce frame |
-| Async | Worker | Read file metadata (size, mtime), check line index cache, compute diff against last known state |
-| Async result | Logic | If diff detected → trigger conflict resolution flow. Otherwise → load chunk around cursor |
-| Async result | Logic | Place cursor, update scroll, produce frame with editor content |
-
-### 4.5 Conflict Resolution
-
-| Step | Path | Operation |
-|---|---|---|
-| File watcher detects change | Async | Worker reads file metadata, classifies as external update or sync conflict |
-| Classification result | Logic | If sync conflict → set conflict icon in status bar, produce frame |
-| User taps conflict icon | Sync | Switch to merge page, set `loading` state, produce frame |
-| Async | Worker | Compute line-based diff between ours and theirs |
-| Async result | Logic | Hunks computed → display first hunk, produce frame |
-| User resolves hunk | Sync | Record choice, advance to next hunk, produce frame |
-| User applies merge | Async | Worker writes merged file, deletes conflict file |
-| Async result | Logic | Switch to editor page, load merged content, produce frame |
-
-### 4.6 Search
-
-| Step | Path | Operation |
-|---|---|---|
-| User activates search | Sync | Show search bar element, set focus, produce frame |
-| User types in search | Sync | Scan loaded chunks for matches, jump cursor to first match, produce frame |
-| User taps up/down | Sync | Jump cursor to previous/next match, produce frame |
-
-### 4.7 Navigation
-
-| Step | Path | Operation |
-|---|---|---|
-| User scrolls | Sync | Update scroll offset, recompute visible lines, produce frame |
-| User taps to place cursor | Sync | Convert tap position → line/col via line index, move cursor, produce frame |
-| User jumps to line | Sync | Binary search on line index, compute scroll offset, produce frame |
-
-### 4.8 Directory Browser
-
-| Step | Path | Operation |
-|---|---|---|
-| User scrolls browser | Sync | Update scroll offset, virtualize visible entries, produce frame |
-| User types in browser search | Sync | Filter entries by query, produce frame |
-| User taps letter in alpha index | Sync | Jump to corresponding section, produce frame |
-| Async | Worker | Load directory entries for newly visible region (lazy loading) |
-
-## 5. State Persistence Strategy
-
-State persistence (writing `state.json`, `cursors.json`, undo stacks) has a **runtime-adaptive strategy**:
-
-### Policy
-
-```
-Strategy: Sync | Async
-
-Default: Sync
-Monitor: rolling average of persistence duration (exponential moving average)
-Threshold: 2 ms average
-
-If average > threshold → switch to Async (log transition)
-If average < threshold/2 while Async → switch back to Sync
-```
-
-### Rationale
-
-On modern mobile devices with UFS storage, writing small JSON files (< 200 KB for typical undo stacks) completes in single-digit milliseconds. Synchronous persistence is simpler (no crash window between in-memory and on-disk state) and provides stronger crash recovery guarantees.
-
-As state grows (large undo stacks, many cursor entries), persistence may exceed the threshold. The runtime switches to async persistence, accepting the bounded crash window (in-memory state ahead of on-disk state, bounded by the 1s debounce).
-
-### Implementation
-
-The persistence function checks an atomic boolean flag (`sync/atomic.Bool`). If `Sync`, it blocks on the file write. If `Async`, it dispatches the write to a worker and returns immediately. The flag is updated by a background profiler goroutine that monitors the rolling average.
-
-### What is Persisted
-
-| Data | Trigger | Debounce |
-|---|---|---|
-| `state.json` | Every navigation, edit, cursor movement | 500 ms |
-| `cursors.json` | Every cursor movement (in any file) | 500 ms |
-| `undo/.json` | Every edit (insert, delete, undo) | 1000 ms (separate from file save) |
-| File content (auto-save) | Every edit | 1000 ms |
-| Line index | On file close, or after bulk edit | Immediate |
-
-## 6. Deadlock Analysis
-
-### Scenario: Logic sends frame, main holds mutex
-
-```
-Logic: frameChan <- elems // blocks if receiver is not reading
-Receiver: lock → store → w.Invalidate() → unlock // fast, sub-ms
-```
-
-**Resolution**: The frame receiver runs a tight loop. It only holds the mutex during the store + invalidate. Logic's send blocks for at most the duration of this store — sub-millisecond. No deadlock.
-
-### Scenario: Main sends input, logic is processing previous input
-
-```
-Main: inputChan <- inputs // blocks if logic is not reading
-Logic: inputs := <-inputChan // select loop, drains before next frame
-```
-
-**Resolution**: Logic's 16ms guarantee means it processes input faster than main produces it. Main sends input after releasing the mutex, so it never holds the lock while blocked on a channel send. No deadlock.
-
-### Scenario: Worker posts result, logic is busy
-
-```
-Worker: resultChan <- result // blocks if logic is not reading
-Logic: select { case r := <-resultChan: ... }
-```
-
-**Resolution**: Worker pool is bounded. Workers post results and return to the pool. If logic is briefly busy processing input, the worker's send blocks for at most the duration of logic's current iteration. The worker is not holding any lock during this send. No deadlock.
-
-### Scenario: Logic dispatches work, all workers busy
-
-```
-Logic: highWorkChan <- workItem // blocks if highWorkChan is full
-Workers: <-highWorkChan // drain at their own pace
-```
-
-**Resolution**: Work channels are buffered to pool size. If all workers are busy, logic's dispatch blocks briefly until a worker frees up. This is acceptable because dispatching work is not on the critical input→frame path. No deadlock.
-
-## 7. Go Concurrency Features Used
-
-| Feature | Where | Why |
-|---|---|---|
-| **Goroutines** | Main, frame receiver, logic, workers | Natural fit for concurrent, channel-coordinated components |
-| **Unbuffered channels** | `frameChan`, `inputChan`, `configChan`, `resultChan` | Synchronous handoff ensures no message is lost; timing guarantees prevent blocking |
-| **Buffered channels** | `highWorkChan`, `lowWorkChan` (size = pool size) | Allows logic to dispatch without waiting for a free worker |
-| **`sync.Mutex`** | Protects current frame | Single critical section (store or read frame), sub-millisecond hold time |
-| **`sync/atomic.Bool`** | Persistence strategy flag | Lock-free read in logic goroutine, written by profiler goroutine |
-| **`context.Context`** | Worker task cancellation | Cancel in-flight loads if user navigates away or file is closed |
-| **`select`** | Logic goroutine's main loop | Multiplex over input, config, and results without polling |
-
-### Features Deliberately Avoided
-
-| Feature | Why avoided |
+| Owned by | What |
|---|---|
-| **`sync.Mutex` on state** | Single-owner pattern: only logic goroutine mutates state |
-| **`sync.RWMutex`** | No readers other than logic; frame access goes through mutex + frame receiver |
-| **`sync.Cond`** | Channels provide the same signaling with less boilerplate |
-| **`errgroup`** | Workers are long-lived pool goroutines, not task-scoped |
-| **`sync.Once`** | No one-time initialization that needs coordination |
-| **`atomic.Value`** | Frame is a slice header (two words); mutex is simpler and safer |
+| **Logic goroutine** | `State` (browser + editor + chunked buffers + line indexes), worker pool, autosave timer handle |
+| **Main goroutine** | `*app.Window`, `op.Ops`, text shaper, `ui.Renderer` (gesture state, IME dedup state, glyph layout cache), the search-bar `widget.Editor` |
-## 8. Browser State Management
+Rules:
-The browser package defines a `BrowserState` struct that holds all browser-specific state. This struct is **embedded** in the editor's `State` struct to maintain the single-owner pattern:
+1. **Gio mutates widgets during draw, so anything Gio mutates must be
+ main-owned.** The search bar is a `widget.Editor` registered with the
+ renderer by ID (`"search_bar"`); its text reaches logic only through
+ `SearchQueryChan`.
+2. **The editor content is NOT a `widget.Editor`.** It is the custom
+ `ui.TextField` element; all editor state (buffer, cursor, scroll) is
+ logic-owned. This is why the editor does not hit `widget.Editor`'s
+ O(n²)-ish cost on large files.
+3. **Per-frame element values are rebuilt by logic** (`[]ui.Element` in the
+ `Frame`); any cross-frame persistent draw-side state (IME dedup, gesture
+ tracking) belongs in the persistent, main-owned `Renderer`.
-```go
-type State struct {
- // ... other fields ...
- Browser BrowserState // Embedded, not a pointer
-}
-```
+## 6. Editor internals (`internal/editor`)
-This ensures:
-1. All browser state is owned by the editor package
-2. The logic goroutine is the sole owner of browser state
-3. No cross-package state access is required
+### 6.1 Chunked buffer
-The browser package's `BrowserLayout` function is called from the editor package, passing the embedded `BrowserState`:
+`ChunkedBuffer` (`chunked_buffer.go`) is the edit buffer for open files.
-```go
-// In editor/state.go
-func (s *State) BrowserLayout(screenW, screenH ui.Dp) []ui.Element {
- return browser.BrowserLayout(screenW, screenH, &s.Browser)
-}
-```
+- **Full load on open** for in-range files: the whole file is read into an
+ ordered slice of chunks (64 KB) plus **prefix-sum byte offsets** over actual
+ chunk lengths. There is no lazy loading and no eviction (both existed as
+ plans and were removed).
+- **Byte-indexed** throughout: `CursorPosition`, chunk offsets, and glyph
+ `ByteOffsets` are byte offsets.
+- Edits splice the affected chunk(s) only; chunks are not rebalanced.
+- `LineIndex` (per-line byte offsets, `int32`) is built asynchronously by
+ `BuildLineIndexTask` and stored on `cb.LineIndex` — the single source of
+ truth (the old parallel `EditorState.LineIndex` field was removed).
+- **Size guard:** files larger than `MaxEditableFileSize` (**50 MB**,
+ measured on-device) open into a `TooLarge` state: the editor shows a notice
+ and edit handlers are no-ops; the browser still lists the file.
-This follows the architecture's principle that the editor package owns all application state, while the browser package provides pure functions for layout computation.
+### 6.2 Virtualized viewport
-## 9. Frame Lifecycle
+Only the visible byte range is shaped and drawn each frame:
-A frame (computed `[]Element`) follows this lifecycle:
+- `VisibleByteRange` maps scroll offset + viewport height → `[startLine,
+ endLine]` via the `LineIndex`, then to a byte range. The range is always
+ bounded by real lines of the document.
+- **Never shape the whole file.** Lesson learned (Phase 3): the shaper's
+ internal `document` retains the backing array of its largest layout forever
+ (`reset()` keeps the cap), so one whole-file layout permanently inflated
+ memory to ~1 GB for a 10 MB file. Shaping ~50 lines keeps it small and flat.
+- The renderer reports `GlyphLayout` back via `LayoutChan`; logic derives
+ `LastLineY` for scroll clamping and the max scroll offset.
-1. **Logic computes**: state mutation + layout → `[]Element`
-2. **Logic sends**: `frameChan <- elems` (blocks only while receiver holds mutex)
-3. **Receiver stores**: `lock → currentElems = elems → w.Invalidate() → unlock`
-4. **Main receives `FrameEvent`**: `lock → renderer.Draw(gtx, currentElems) → e.Frame(&ops) → unlock`
-5. **Frame is on screen**: Gioui has submitted the frame to the display
-6. **Next frame**: logic computes a new `[]Element`, repeats from step 2
+### 6.3 IME (Android soft keyboard)
-At no point are two frames rendered simultaneously. The mutex ensures the main goroutine never reads a frame while the receiver is writing it. The frame receiver calls `w.Invalidate()` only after storing, so Gioui never requests a draw for a frame that hasn't been stored yet.
+- The editor exposes to the IME a **windowed snippet**: `IMEWindowText` is the
+ visible viewport text, `IMEWindowStartByte` its absolute start. While the
+ `TextField` is focused, `drawElement` emits `key.SnippetCmd` (the window)
+ and `key.SelectionCmd` (caret as a rune index into the window).
+- **Dedup in the Renderer** (main-owned state): the snippet/selection are
+ re-emitted only when they actually change, and a fresh push is forced when
+ the field (re)gains focus. This mirrors `widget.Editor`'s behavior and
+ prevents per-frame re-push from resetting IME composition on rapid commits.
+- Incoming `key.EditEvent{Range, Text}` has **window-relative rune
+ indices**; `HandleReplaceRange` converts them to absolute byte offsets
+ (`RuneIndexToByte`) and splices the chunked buffer. Insertion,
+ `deleteSurroundingText` (backspace/autocorrect replacement), and composition
+ all arrive through this one path.
-## 9. Process Death and Recovery
+### 6.4 Autosave
-The concurrency model has no shared mutable state between goroutines (state is owned by the logic goroutine, frame is protected by mutex). On process death:
+- Any edit calls `markDirty()`: a 1 s debounce timer; each keystroke restarts
+ it. On expiry the timer goroutine sends a token on `autosaveChan`; the owner
+ reconstructs the full content from the chunked buffer and dispatches a
+ `WriteFile` task.
+- Write failures are tracked per file (`writeFailed`, `retryAttempts`) and
+ retried via `retryChan`. There is no save button; autosave is the only
+ persistence.
-- **In-memory state is lost**: restored from `.pad/state.json`, `.pad/cursors.json`, `.pad/undo/`
-- **In-flight worker tasks are lost**: workers are short-lived; tasks are idempotent (file reads, diff computation)
-- **In-flight persistence is lost**: bounded by the debounce window; last persisted state is the recovery point
-- **Merge state does not survive**: if the app dies during conflict resolution, the merge session is re-computed on relaunch (diff is idempotent)
+### 6.5 Opening a file
-The recovery flow (SPEC §8) reads persisted state, restores the active file, cursor, and undo stack. The concurrency model restarts fresh: new goroutines, new channels, same invariants.
+- A browser tap sends the path on `OpenFileChan`. The logic goroutine creates
+ the `ChunkedBuffer`, dispatches `StatFile` (size guard) and the read +
+ `BuildLineIndex` tasks, switches `page` to the editor, and sets
+ `justOpenedAt`.
+- **Opening-tap swallow:** the tap that opens the file is also delivered as an
+ editor tap in the same frame. A short time window (`justOpenedAt`) swallows
+ it so the viewport does not jump to the tapped (often EOF) position.
-## 10. Memory Monitoring and Management
+## 7. Browser internals (`internal/browser`)
-Pad implements a monitoring system for heap usage and latency, with hooks for future global memory management.
+- `BrowserState` is **embedded by value** in `State` (single owner; no
+ pointer indirection).
+- `BrowserManager` drives async directory loading through the worker pool:
+ `ReadDir` + `BuildIndex` on navigation, `LoadPages` for pagination.
+- Features: 4 sort modes (name/date × asc/desc; default newest-first),
+ incremental case-insensitive search over names, directory navigation with
+ back, single-tap file open.
+- 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).
-### 10.1 Monitoring
+## 8. Render pipeline (`internal/ui`)
-A background profiler goroutine periodically collects system metrics:
+- **Logic** builds a `[]ui.Element` tree. Elements are value types; interactive
+ ones carry `Interaction{Gesture, Handler}` entries. `Handler` is a static
+ function that mutates `TheState` — handlers run on the logic goroutine only.
+- **Main** draws via `Renderer.Draw(gtx, elems, scale)`:
+ 1. `drawElement` recursively walks the tree, applying transforms/clips.
+ 2. Interactive elements register hit regions in the *current clip context*
+ (`gesture.Click.Add` / `pointer.InputOp`), so hit-testing always matches
+ the drawn geometry.
+ 3. Text elements shape through the shared shaper — **only the visible
+ window**, never the whole buffer.
+ 4. `e.Frame(&ops)` flushes ops.
+- After draw, `CheckGestures` turns raw pointer/gesture state into
+ `[]ui.InputEvent` (each carrying its own handler) for the logic channel.
+- **Units:** `ui.Dp`/`ui.Px` convert via the current `PxPerDp` scale
+ (`ToDp`/`ToPx`). The window is 390×844 dp; the real pixel size arrives via
+ `ConfigEvent`.
+- **Live element catalog:** `Container`, `Label`, `Icon`, `TextField` (editor
+ window), `ListView`/`Line`/`ListItem` (browser rows), `GioEditor` (search
+ bar), `Cursor`. Types `Button`, `Toast`, `Spacer`, `AlphaIndex`,
+ `Selection`, `MergeHunk` exist in `element.go` but are not used by any page
+ — future work, not current behavior.
+- **Theme:** `ui.Theme{FontSize: 14}` + palette in `element.go`.
-- **Heap Usage**: Collected via `runtime.ReadMemStats`. Tracks total allocated memory and heap size.
-- **Persistence Latency**: Tracks the EMA of state persistence duration (see §5).
-- **Logic Latency**: Tracks the time taken for the Logic goroutine to process input and compute layout.
+## 9. Testing hooks
-These metrics are reported on a **Debug Page** within the application, allowing for real-time performance analysis during development and by power users.
+- `Logic.Inspect(fn)` (test-only): runs `fn(*State)` **on the owner** and
+ returns its result — the sanctioned way for tests to read state without
+ breaking single ownership. It must never be called from production code.
+- The e2e harness (`internal/test/e2e`) drives the **real** `Logic` + worker
+ pool + mock filesystem through the real channels, and asserts via
+ `Inspect`. It is logic-only: it does not exercise `Renderer.Draw`, so
+ draw-path behavior (IME dedup, gesture routing) is validated on-device with
+ adb (see `development_plan.md` §Phase 2/3 observation loop).
+- CI gate: `go build ./... && go vet ./... && go test -race ./...`.
-### 10.2 Memory Pressure (Future Enhancement)
+## 10. Known loose ends (code, not spec)
-While initial versions use per-component eviction (e.g., LRU for chunk cache and line indices), a future **Global Memory Governor** is planned:
-
-- **Centralized Eviction**: Instead of components deciding when to evict independently, the Governor monitors total heap pressure.
-- **Pressure Tiers**:
- - **Normal**: No proactive eviction.
- - **Warning**: Proactively evict oldest line indices and non-visible file chunks.
- - **Critical**: Evict all non-visible data; force garbage collection.
-- **Implementation**: Components register "evictable" resources with the Governor, which calls back to release memory when thresholds are exceeded.
-
-This ensures that the "No Practical Limits" philosophy does not lead to Out-Of-Memory (OOM) crashes on devices with limited RAM.
+- Globals `TheState` / `TheLogic` / `ui.OpenFile` still exist (handlers are
+ static funcs); replacing them with explicit state is a planned cleanup
+ (`development_plan.md` Phase 4).
+- Dead task types (`ReadChunk`, `SaveState`, `SaveUndo`, …) and unused element
+ types are candidates for removal in the same round.
diff --git a/doc/browser_implementation_plan.md b/doc/browser_implementation_plan.md
deleted file mode 100644
index 2150793..0000000
--- a/doc/browser_implementation_plan.md
+++ /dev/null
@@ -1,1133 +0,0 @@
-# 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-06-02)
-
-| 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/` |
-| Mock filesystem-backed entries | ✅ Implemented | `internal/browser/manager.go` |
-| Lazy loading / pagination | ✅ Implemented | `internal/browser/manager.go` |
-| 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
-
-```go
-// 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)
-
-```go
-// 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
-
-```go
-// 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
-
-```go
-// 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_.json
-```
-
-```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
-
-```go
-// 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
-
-```go
-// 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)
-
-```go
-// 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
-
-```go
-// 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
-
-```go
-// 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`:
-
-```go
-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:
-
-```go
-// 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`:
-
-```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`:
-
-```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)
-- [x] `internal/browser/types.go` — Define Entry, Page, BrowserState
-- [x] `internal/browser/index.go` — Directory index build/cache
-- [x] `internal/browser/browser.go` — State management, page loading
-- [x] `internal/browser/layout.go` — BrowserLayout function
-- [x] Wire into main event loop (replace placeholder browser)
-- [x] Basic scroll and tap-to-open
-
-### Phase 2: Lazy Loading (Detailed Plan)
-
-Completed:
-- [x] Create `BrowserManager` orchestrator
-- [x] Wire initial directory load via `BuildIndexTask`
-- [x] Implement scroll-based prefetching
-- [x] Handle worker results in logic goroutine
-- [x] Implement page eviction
-- [ ] Implement dirty page refresh (Pending)
-- [x] Write E2E tests with mock filesystem
-
-### 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
-
-### Phase 4: Search
-- [x] Search bar UI
-- [x] Incremental filtering
-- [x] Search result navigation
-
-### Phase 5: Polish
-- [ ] Scroll momentum (reuse from touch.md §6)
-- [ ] External change detection (watcher integration)
-- [ ] State persistence and restore
-- [ ] Real filesystem backing (transition from mock)
-- [ ] 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:**
-```go
-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:**
-```bash
-# 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 | ✅ Complete | `loadPageFromIndex()`, `LoadInitialPages()` wired in `BrowserManager` |
-| Worker pool | ✅ Complete | Priority dispatch, `LoadPagesTask` defined and used |
-| Mock filesystem | ✅ Complete | Thread-safe, configurable delay |
-| Test harness | ✅ Complete | Frame capture, input simulation |
-
-**Gaps to Fill:**
-
-1. **Implement 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 (Pending)
-
-When a page is marked dirty:
-1. Reload from index
-2. Merge into `Pages` map
-
-**Dirty detection:**
-- External file changes (via file watcher - *To be implemented*)
-- 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.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 |
diff --git a/doc/bugs.txt b/doc/bugs.txt
deleted file mode 100644
index fef6707..0000000
--- a/doc/bugs.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-This file lists descriptions of outstanding bugs. Bugs are separated by empty lines.
-
diff --git a/doc/conflict_resolution.md b/doc/conflict_resolution.md
deleted file mode 100644
index 0c91c02..0000000
--- a/doc/conflict_resolution.md
+++ /dev/null
@@ -1,233 +0,0 @@
-# Conflict Resolution Specification
-
-## 1. Overview
-
-Pad operates on a directory synced by Syncthing. External modifications to files originate from edits on other devices, delivered via Syncthing. This document specifies how Pad detects, classifies, and resolves such changes.
-
-## 2. Syncthing Conflict File Format
-
-When Syncthing detects that two devices have modified the same file, it preserves both versions:
-
-- **Original file** (`notes.txt`) — the local device's version (what Pad edited)
-- **Conflict file** (`notes.sync-conflict-2024-05-15-1430-ABCDEF1.txt`) — the remote device's version
-
-**Naming pattern:** `.sync-conflict--