Pad/doc/editor_implementation_plan.md
Greg Pomerantz d73cb0be2c Integrate real filesystem with interface abstraction
- Define pool.FileSystem interface in internal/io/pool/filesystem.go
- Move DirEntry interface to internal/io/pool/types/types.go to break
  the pool <-> mock import cycle
- Implement real.FileSystem with atomic writes (temp file + os.Rename)
- Update mock.FileSystem to satisfy pool.FileSystem interface
- Update worker pool tasks to accept pool.FileSystem interface
- Update main.go to use RealFileSystem by default, with -root flag
- Update all browser/editor/test references to types.DirEntry
2026-06-04 16:19:38 -04:00

504 lines
26 KiB
Markdown

# Editor Implementation Plan
## 1. Design Principles
- **Single-Owner Pattern**: The logic goroutine is the exclusive authority for modifying the editor buffer, cursor, and selection state.
- **Asynchronous I/O**: File saving and loading operations are delegated to the worker pool. The UI never blocks on disk operations.
- **Buffer-Based Editing**: For performance, the editor will eventually utilize a gap buffer or rope data structure. For MVP, string manipulation is acceptable if performance targets are met.
- **Event-Driven**: Keyboard input, mouse interactions, and menu actions are passed through the existing `inputChan` to the logic goroutine.
## 2. Core State Structure
The `State` struct in `internal/editor/state.go` is extended to track active editing session data:
```go
type EditorState struct {
Buffer string
CursorPosition int
GlyphLayout GlyphLayout
SelectionStart int // -1 if no selection (unused)
SelectionEnd int // -1 if no selection (unused)
CursorVisible bool // Blink state (no timer yet)
Filename string // Current file path being edited
fileVersion map[string]int // per-file: version of the loaded buffer
lastWriteVersion map[string]int // per-file: version of last successful write
saveTimer *time.Timer // nil = no pending save
}
```
**Dirty** is computed, not stored:
```go
func (e *EditorState) IsDirty() bool {
bufVer := e.fileVersion[e.Filename]
writeVer := e.lastWriteVersion[e.Filename]
return bufVer > writeVer
}
```
**Current state** (as of June 2026): `EditorState` is defined with `Dirty bool` (single boolean, cleared on file switch). `UndoStack` is deferred. `SelectionStart`/`SelectionEnd` are present but unused. **Save file feature not yet implemented.**
## 3. Auto-Save Design
Auto-save is transparent, asynchronous, and fire-and-forget. No save button, no UI indicators, no "do you want to save?" dialog.
### 3.1 Per-File Version Tracking
The `Dirty` boolean is replaced by per-file version maps:
| Field | Meaning |
|---|---|
| `fileVersion[filename]` | Version of the buffer currently loaded for this file (monotonically increasing) |
| `lastWriteVersion[filename]` | Version of the last **successful** write for this file |
| **Dirty** | Computed: `fileVersion > lastWriteVersion` |
**Dirty lifecycle:**
| Event | Effect |
|---|---|
| Edit | `fileVersion[filename]++` → Dirty becomes true |
| Write succeeds | `lastWriteVersion[filename] = fileVersion[filename]` → Dirty becomes false |
| Write fails | `lastWriteVersion` unchanged → Dirty stays true |
| Switch files | Old file's versions preserved in map → Dirty preserved |
| Re-open file | `fileVersion` preserved (not reset) → Dirty preserved |
| Next edit on dirty file | New save attempt (retry) |
This means:
- Dirty state survives file switches
- Dirty state survives file re-opens
- Failed writes leave the file dirty
- No automatic retry — the next edit triggers a new save attempt
### 3.2 Debounce Timer
A 1-second debounce timer fires after the last edit. The timer is reset on each new edit, so only one write is dispatched per debounce window.
```go
// In inputChan handler, after processing events:
if l.state.Editor.IsDirty() && l.state.Editor.Filename != "" {
if l.saveTimer != nil {
l.saveTimer.Stop()
}
gen := l.saveGeneration
l.saveGeneration++
buffer := l.state.Editor.Buffer // capture in logic goroutine
l.saveTimer = time.AfterFunc(1*time.Second, func() {
if l.saveGeneration == gen {
l.workerPool.DispatchNonBlocking(
pool.NewWriteFileTask(l.state.Editor.Filename, []byte(buffer), l.mockFS),
)
}
})
}
```
### 3.3 Generation Counter
The `saveGeneration` field prevents stale timer callbacks from dispatching writes:
```go
type Logic struct {
saveTimer *time.Timer
saveGeneration int // incremented each time a new timer starts
}
```
Each timer callback captures its own `gen` value. When the callback fires, it checks `l.saveGeneration == gen`. If they differ, the timer was replaced and the callback drops silently.
**Why `Stop()` alone isn't enough:** `time.AfterFunc` creates a goroutine that waits on a timer channel. `Stop()` closes the channel, preventing future firings. But if the callback goroutine has already woken up (the timer fired), `Stop()` doesn't interrupt it. The generation check handles this in-flight case.
**Scenario: Two rapid edits**
```
Time 0s: Edit → gen=0, saveGen=1, Timer A starts
Time 0.5s: Edit → gen=1, saveGen=2, Timer A stopped, Timer B starts
Time 1.0s: Timer A fires → gen(0) != saveGen(2) → drop
Time 1.5s: Timer B fires → gen(1) == saveGen(2) → dispatch
```
Only the latest timer dispatches. All others are filtered by the generation check.
### 3.4 Failure Mode Handling
Three likely failure modes:
| Failure | Recoverable? | Response |
|---|---|---|
| Device I/O error | No | Log error, leave dirty, no retry |
| Network filesystem unavailable | Maybe | Log error, leave dirty, no retry |
| Removable media not found | No (not relevant on mobile) | Log error, leave dirty, no retry |
**No automatic retry.** The dirty state persists until the next edit triggers a new save attempt. If the filesystem recovers (e.g., network comes back), the next save will succeed. If not, the file stays dirty and the user will eventually notice.
### 3.5 Stale Result Filtering
Write results are filtered by file path:
```go
case res.TaskType == pool.TypeWriteFile:
if res.FilePath != l.state.Editor.Filename {
return // File switch — stale result for a different file
}
if res.IsSuccess() {
l.state.Editor.markSaved()
} else {
log.Printf("Auto-save failed for %s: %v", res.FilePath, res.Error)
// Dirty stays true — next edit triggers retry
}
```
When a write fails, `lastWriteVersion` is not updated. The file stays dirty. The next edit on that file triggers a new save attempt.
### 3.6 File Switch While Save In Flight
```
Edit A → Dirty=true → timer starts
Timer fires → write dispatched
Open file B → timer cancelled, dirty cleared for B
Write A result arrives → path check: A != B → ignored
```
The file switch cancels the timer and clears dirty for the new file. An in-flight save for the old file is harmless — its result is ignored by the path check.
### 3.7 Atomic Write (Temp + Rename)
The mock filesystem implements atomic writes via temp file + rename:
```go
func (fs *FileSystem) WriteFileAtomic(path string, content []byte) error {
// 1. Write to .tmp/ subdirectory (top-level, outside user directories)
tempPath := ".tmp/" + filepath.Base(path)
fs.files[tempPath] = &File{...}
// 2. Atomic rename — insert + delete in one locked operation
fs.files[path] = &File{...}
delete(fs.files, tempPath)
// 3. Send change notifications (outside lock)
fs.sendNotifications(...)
}
```
Key design points:
- Temp files live in top-level `.tmp/` — never in any user directory
- The rename is atomic: both insert and delete happen while holding the mutex
- No partial writes are ever visible
- Change notifications fire after the lock is released
### 3.8 Concurrency Correctness
| Concern | How it's handled |
|---|---|
| Multiple writes in flight | Generation counter prevents old timer callbacks from dispatching |
| Out-of-order completion | Not an issue — only one write dispatched at a time (guaranteed by generation counter) |
| Write fails | `lastWriteVersion` unchanged → dirty stays true → next edit retries |
| Write dropped (channel full) | No result → dirty stays true → next edit retries |
| File switch while save in flight | Timer cancelled; stale result ignored by path check |
| Re-open dirty file | `fileVersion` preserved → dirty stays true → next edit retries |
| Buffer race with timer callback | Buffer captured in logic goroutine before timer starts |
| No blocking on critical path | `DispatchNonBlocking` never blocks; timer callback is separate goroutine |
## 4. Implementation Phases
### Phase 1: Basic Buffer Management & Cursor
- [x] Define `EditorState` (Cursor, Selection, Dirty flag). — **DONE**
- [x] Implement `GlyphLayout` capture in `drawWrappedText` and feedback via `layoutChan`. — **DONE**
- [x] Implement cursor navigation (Arrow keys: Left, Right, Up, Down). — **DONE** (Left/Right: byte-based ±1; Up/Down: GlyphLayout-based)
- [x] Implement basic buffer updates (Insert character, Delete/Backspace). — **DONE**
- [x] Implement cursor display. — **DONE** (Rendered inline in `Renderer.drawWrappedText` using `GlyphLayout`)
- [ ] Implement cursor blink animation. — **NOT STARTED** (`CursorVisible` field exists but no timer drives it)
- [x] Tap-to-position cursor from GlyphLayout. — **DONE** (`SetCursorFromPoint` groups glyphs by Y, finds closest X)
### Phase 2: File IO Integration
- [x] Implement `SaveFile` handler: Debounced auto-save via `WriteFileTask` to worker pool. — **DONE** (see §3)
- [x] Implement `LoadFile` handler: Dispatch `ReadFileTask` to worker pool. — **DONE**
- [ ] Status bar integration: Show "Saving..." indicator, "Modified" status. — **UPDATED**: Added `<dirty>` indicator when `WriteFailed()`. status bar also needs "Saving..." indicator.
### Phase 3: Text Editing Operations
- [ ] Implement Cut/Copy/Paste interactions. — **NOT STARTED** (icon elements exist but handlers are `nil`)
- [ ] Implement multi-line text navigation (Home/End, PageUp/PageDown). — **NOT STARTED**
- [ ] Implement text selection display and mouse interaction. — **NOT STARTED** (`SelectionStart`/`SelectionEnd` fields exist in `EditorState` but are unused)
- [x] Implement soft-wrap toggle. — **DONE** (`ToggleWordWrap` in `state.go`, wired to bottom bar "Wrap" label tap)
### Phase 4: Polish & Advanced Features
- [ ] Undo/Redo stack implementation. — **NOT STARTED** (`UndoStack` field is commented out in `EditorState`)
- [ ] Performance testing with large files (>1MB). — **NOT STARTED**
### 4.5 Real Filesystem Integration
To support real filesystem operations with minimal changes, we are abstracting the filesystem behind an interface.
### 4.5.1 Design
- **Interface Definition**: Define a `FileSystem` interface in `internal/io/pool/` that captures the required operations (`ReadDir`, `ReadFile`, `WriteFile`, `DirExists`, etc.).
- **Mock Update**: Update the existing `internal/io/pool/mock` to satisfy this interface.
- **Real Implementation**: Create `internal/io/pool/real` that implements the `FileSystem` interface using standard `os` and `path/filepath` packages.
- **Dependency Injection**: Update worker pool tasks to accept the `FileSystem` interface instead of the concrete `*mock.FileSystem` type.
- **Atomic Writes**: The `RealFileSystem` must also implement atomic writes via temporary files and `os.Rename` to maintain the consistency guarantees of the mock.
### 4.5.2 Implementation Roadmap
1. Define `pool.FileSystem` interface.
2. Update `mock.FileSystem` to satisfy the interface.
3. Update `pool` tasks to accept the interface.
4. Implement `real.FileSystem`.
5. Update `main.go` to inject the correct implementation based on build/runtime flags.
---
## 5. Interaction Flow (Example: Keyboard/Mouse Input)
1. **User** performs an action (types a character, taps an icon).
2. **UI (Renderer)** registers the element's interaction (`event.Op` for keys, `gesture.Add` for mouse) within its clip context.
3. **Main Loop** captures the event (using `key.Filter` for keys, `gesture.Update` for mouse).
4. **Main Loop** generates an `InputEvent` and sends it to `inputChan`.
5. **Logic Goroutine** receives the event, triggers the corresponding `Handler` in `TheState`.
6. **Logic Goroutine** modifies state, and sends updated elements back through `frameChan`.
7. **Renderer** paints the new frame.
---
## 6. Performance Targets
| Operation | Target | Mechanism |
|---|---|---|
| Typing latency | < 16ms | Single-owner logic update, immediate frame redraw |
| File Load (1MB) | < 100ms | Async worker pool read, background loading |
| File Save | < 100ms | Async worker pool write |
| Undo/Redo | < 10ms | O(1) or O(N) memory-based buffer update |
---
## 7. Input Handling Mechanism
### 7.1 Mouse/Touch Input
- Uses `gesture.Click` and `gesture.Scroll`.
- Registered via `event.Op` and gesture `Add()` within the clip context of the element.
- Processed by `Renderer.CheckGestures` and dispatched via `InputEvent` to the logic goroutine.
### 7.2 Keyboard Input
- **Registration**: Elements register as input handlers using `event.Op(gtx.Ops, elementID)`. This tags the current clip context for input routing.
- **Focus**: Focus is managed by the logic goroutine. When an element is focused, `key.FocusCmd{Tag: elementID}` is submitted to the operation stack.
- **Filtering**: Keyboard events are processed in the main loop using `key.Filter{Focus: focusedElementID}` to ensure events are only routed to the active element.
- **Routing**: `key.Event` (for key presses) and `key.EditEvent` (for text input) are converted into `InputEvent` structs and sent to the logic goroutine via `inputChan` for state updates.
---
## 8. Glyph Layout Architecture
### 8.1 Problem
The editor must know the exact screen position of every character after shaping and word wrapping. This is required for:
- Accurate cursor rendering (no fixed-width approximation)
- Correct cursor navigation (arrow keys respect visual line boundaries)
- Scroll-aware positioning (cursor follows text when viewport changes)
- Screen resize resilience (cursor tracks text as wrap points shift)
- Future features: mouse click-to-place, text selection, search highlight positioning
### 8.2 Core Principle
**The renderer is the single source of truth for where characters appear on screen.** Logic never guesses positions it reads the layout the renderer already computed during the draw pass.
### 8.3 Data Structure
```go
type GlyphLayout struct {
ByteOffsets []int // byte offset of each glyph in the buffer
X []Dp // screen X (Dp) of each glyph, relative to text region origin
Y []Dp // screen Y (Dp) of each glyph, relative to text region origin
Advance []Dp // advance width (Dp) of each glyph
}
```
Each index `i` represents one glyph. `ByteOffsets[i]` is the byte position in the buffer, `(X[i], Y[i])` is its screen location, and `Advance[i]` is its width. The slice length equals the total number of glyphs (one per rune).
**Derived values:**
- `LastLineY` = `Y[len(Y)-1]` (last glyph's baseline Y) replaces the separate `lastLineY` feedback
- Visual line breaks = any index `i` where `Y[i] > Y[i-1]`
- Cursor at byte offset `b` binary search `ByteOffsets` for exact match, then read `(X[idx], Y[idx])`
Storing all X advances is not expensive a single page of text is a tiny amount of memory.
### 8.4 Capture Point
**Implemented.** `drawWrappedText` in `render.go` captures full per-glyph layout data during the shaping loop:
```go
func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, wrapWidth Dp, scrollOffset Dp, cursorPos int) {
// ... shaping setup with WrapHeuristically ...
r.shp.LayoutString(params, str)
var layout GlyphLayout
byteOffset := 0
for g, ok := r.shp.NextGlyph(); ok; g, ok = r.shp.NextGlyph() {
layout.ByteOffsets = append(layout.ByteOffsets, byteOffset)
layout.X = append(layout.X, Dp(float32(g.X>>6)/r.scale.Scale()))
layout.Y = append(layout.Y, Dp(float32(g.Y)/r.scale.Scale()))
layout.Advance = append(layout.Advance, Dp(float32(g.Advance>>6)/r.scale.Scale()))
for i := uint16(0); i < g.Runes; i++ {
_, sz := utf8.DecodeRuneInString(str[byteOffset:])
byteOffset += sz
}
// ... draw logic ...
}
// Store captured layout; derive lastLineY from it.
r.glyphLayout = layout
if len(layout.Y) > 0 {
r.lastLineY = layout.Y[len(layout.Y)-1]
}
}
```
The layout is stored on the renderer as `r.glyphLayout`. `lastLineY` is derived from `layout.Y[len-1]`. Exposed via `Renderer.GlyphLayout()`.
### 8.5 Feedback Path
**Implemented.** A `layoutChan` carries the full `GlyphLayout` from renderer to logic. `LastLineY` is derived from `GlyphLayout.Y[len-1]`.
```go
// main.go — after each FrameEvent
glyphLayout := renderer.GlyphLayout()
logic.LayoutChan() <- glyphLayout
// logic.go
case layout := <-l.layoutChan:
l.state.Editor.GlyphLayout = layout
var derivedLastLineY ui.Dp
if len(layout.Y) > 0 {
derivedLastLineY = layout.Y[len(layout.Y)-1]
}
if derivedLastLineY != l.state.LastLineY {
l.state.LastLineY = derivedLastLineY
l.frameChan <- l.state.layout(l.browserManager)
}
```
### 8.6 Editor State Consumption
**Implemented.** `EditorState` contains a `GlyphLayout` field. All cursor operations are GlyphLayout-based.
```go
type EditorState struct {
Buffer string
CursorPosition int
GlyphLayout GlyphLayout
// ...
}
```
**Cursor rendering** handled inline in `drawWrappedText` (`render.go`). After shaping, the renderer binary-searches `ByteOffsets` for `cursorPos` and draws a 2dp vertical bar at the computed position. No separate cursor element is needed in the element tree.
**Cursor movement** (`HandleCursorMove`, `state.go`) byte-based ±1 with bounds clamping. For proportional fonts this is a reasonable approximation; a glyph-index-based version could be added later.
**Vertical cursor movement** (`HandleVerticalCursorMove`, `state.go`) fully GlyphLayout-based. Finds the current glyph's Y via binary search, scans for the target line's Y, then picks the glyph with the closest X on that line.
**Tap-to-position** (`SetCursorFromPoint`, `state.go`) GlyphLayout-based. Groups glyphs by Y-baseline, identifies the target visual line from the tap Y, then finds the closest glyph by X distance on that line. Handles edge case of tapping past the rightmost glyph.
### 8.7 Correctness Guarantees
| Scenario | How it works |
|---|---|
| **Word wrap** | Shaper's `WrapHeuristically` produces different Y values at wrap points. Layout captures them exactly. |
| **Screen resize** | Next frame new `wrapWidth` shaper produces new layout main loop sends new layout cursor follows text. |
| **Editing** | Buffer changes layout re-computed on next frame `ByteOffsets` reflect new positions. |
| **Scroll** | Y values are computed minus `scrollOffset` cursor Y tracks naturally. |
| **LastLineY** | Derived from `layout.Y[len-1]` no separate tracking needed. |
| **Non-fixed-width fonts** | `Advance` comes from actual glyph metrics, not approximation. |
| **Empty buffer** | Layout is empty (`len == 0`). Cursor rendering handles this as the "after last glyph" case. |
### 8.8 Summary of Changes
| File | Status | Notes |
|---|---|---|
| `internal/ui/unit.go` | **Done** | `GlyphLayout` struct defined with `ByteOffsets`, `X`, `Y`, `Advance` |
| `internal/ui/render.go` | **Done** | `drawWrappedText` captures full `GlyphLayout`; derives `lastLineY` from it; renders cursor inline |
| `internal/ui/element.go` | **Done** | `TextField.Draw` passes `CursorPosition` to `drawWrappedText` |
| `internal/editor/logic.go` | **Done** | Uses `layoutChan` (full `GlyphLayout`) instead of `lastLineYChan` |
| `internal/editor/state.go` | **Done** | `GlyphLayout` stored on `EditorState`; cursor rendering handled by `Renderer` |
| `cmd/pad/main.go` | **Done** | Sends `renderer.GlyphLayout()` on `LayoutChan()` after each frame |
---
## 9. Current Implementation Status (as of June 2026)
This section documents what has been implemented beyond the original plan, and what gaps remain.
### 9.1 Implemented Components
| Component | Status | Notes |
|---|---|---|
| `EditorState` struct | **Done** | `Buffer`, `CursorPosition`, `GlyphLayout`, `fileVersion`, `lastWriteVersion`, `Filename`, `saveTimer` |
| `GlyphLayout` capture | **Done** | Full per-glyph capture in `drawWrappedText` (ByteOffsets, X, Y, Advance) |
| `layoutChan` feedback | **Done** | Main loop sends `GlyphLayout` after each frame; logic derives `LastLineY` from it |
| `HandleInsert` | **Done** | String concatenation at cursor position, advances cursor, calls `markDirty()` |
| `HandleBackspace` | **Done** | Removes byte before cursor, decrements cursor, calls `markDirty()` |
| `HandleDelete` | **Done** | Removes byte after cursor, calls `markDirty()` |
| `HandleCursorMove` | **Done** | Byte-based ±1 movement, bounded by buffer length |
| `HandleVerticalCursorMove` | **Done** | GlyphLayout-based: binary search for current Y, scan for target Y, find closest X |
| `SetCursorFromPoint` | **Done** | GlyphLayout-based tap-to-position: groups glyphs by Y, finds closest X on target line |
| `HandleKeyDown` | **Done** | Dispatches `key.Name` (arrows, delete, return) and `key.EditEvent` (text input) |
| `EditorLayout` | **Done** | Full layout: status bar, text field, bottom bar |
| Cursor rendering | **Done** | Inline in `Renderer.drawWrappedText` 2dp vertical bar positioned from `GlyphLayout` |
| File open flow | **Done** | `OpenFile` `ReadFileTask` worker pool `handleWorkerResult` `Editor.Buffer` |
| Soft-wrap toggle | **Done** | `ToggleWordWrap` wired to bottom bar label |
| Page navigation | **Done** | Browser Editor page switching via `GoToBrowser`/`GoToEditor` |
| Keyboard focus | **Done** | `FocusedElementID` + `key.FocusCmd` + `key.Filter`/`key.FocusFilter` routing |
| Scroll handling | **Done** | `HandleScroll` clamped to `[0, MaxScroll]` derived from `LastLineY` |
| Mock filesystem | **Done** | `populateMockFileSystem` with realistic directory structure |
| Worker pool | **Done** | 4-goroutine pool with `ReadFileTask`, `BuildIndexTask`, `WriteFileTask`, etc. |
| Per-file dirty tracking | **Done** | `fileVersion` + `lastWriteVersion` maps, computed `IsDirty()` |
| Auto-save debounce | **Done** | 1s debounce with generation counter, `DispatchNonBlocking` |
| Atomic write (mock) | **Done** | `WriteFileAtomic` temp file + mutex-protected rename |
| Stale result filtering | **Done** | Path check + version tracking in `handleWorkerResult` |
| `WriteFileTask` atomic | **Done** | `Execute()` calls `WriteFileAtomic`, `Result` carries `FilePath` |
| Generation counter | **Done** | `saveGeneration` prevents stale timer callbacks from dispatching |
| `OpenFile` filename | **Done** | Tracks `Filename`, cancels pending timer on file switch |
| Dynamic filename in UI | **Done** | `EditorLayout` uses `TheState.Editor.Filename` |
| `markDirty()` / `markSaved()` | **Done** | Helper methods on `EditorState` |
| `IsDirty()` computed | **Done** | `fileVersion > lastWriteVersion` |
| `Result.FilePath` field | **Done** | Added to `Result` struct for stale result filtering |
| `WriteFileAtomic` in mock | **Done** | Top-level `.tmp/` directory, atomic rename, no partial writes |
### 9.2 Test Coverage
| Test File | Coverage |
|---|---|
| `buffer_test.go` | `TestInsertChar` verifies insert at end of buffer |
| `buffer_test.go` | `TestBackspace` verifies backspace at end of buffer |
| `cursor_test.go` | `TestCursorPositioning_Basic` verifies GlyphLayout-based tap-to-position |
| `cursor_test.go` | `TestCursorPositioning_EmptyDocument` verifies empty document handling |
| `cursor_test.go` | `TestCursorPositioning_IncompleteLayout` verifies graceful handling of partial layout |
| `cursor_test.go` | `TestHandleCursorMove_Bounds` verifies cursor clamping at buffer start/end |
| `integration_test.go` | `TestOpenFileIntegration` verifies full file open flow through worker pool |
| `auto_save_test.go` | `TestAutoSave_Fires` edit debounce WriteFileTask dispatched |
| `auto_save_test.go` | `TestAutoSave_DebounceCoalesces` rapid edits single WriteFileTask |
| `auto_save_test.go` | `TestAutoSave_GenerationFiltering` old timer callbacks drop |
| `auto_save_test.go` | `TestAutoSave_FileSwitchCancelsTimer` switch file old not saved |
| `auto_save_test.go` | `TestAutoSave_StaleResultIgnored` result for different file ignored |
| `auto_save_test.go` | `TestAutoSave_FailureLeavesDirty` write fails dirty stays true |
| `auto_save_test.go` | `TestAutoSave_SuccessClearsDirty` write succeeds dirty becomes false |
| `auto_save_test.go` | `TestAutoSave_PersistsAcrossSwitch` edit A switch B switch A A still dirty |
| `auto_save_test.go` | `TestAutoSave_AtomicWrite` temp file + rename, no partial writes visible |
### 9.3 Known Gaps
| Gap | Description |
|---|---|
| Cursor blink | `CursorVisible` field exists but no blink timer is driven |
| Home/End/PageUp/PageDown | No handlers for these keys |
| Cut/Copy/Paste | Icon elements exist with `nil` handlers |
| Text selection | `SelectionStart`/`SelectionEnd` fields present but unused |
| Undo/Redo | Commented out in `EditorState`, no implementation |
| Status bar dynamics | Filename, line/col, byte count are static strings |
| `Dirty` flag UI | `Dirty` is computed but not displayed in status bar |
| Real filesystem backend | Mock `WriteFileAtomic` uses in-memory `.tmp/`; production needs `.pad/tmp/` temp+rename |
| File close save | No explicit save-on-file-close; last edits rely on debounce timer |
### 9.4 File-by-File Change Summary for Auto-Save
| File | Changes |
|---|---|
| `internal/editor/state.go` | Added `Filename`, `fileVersion`, `lastWriteVersion`, `saveTimer`; removed `Dirty` boolean; added `IsDirty()`, `markDirty()`, `markSaved()`; updated `OpenFile` to cancel timer and track filename |
| `internal/editor/logic.go` | Added `saveGeneration`; added debounce logic in `inputChan` handler; added `TypeWriteFile` handling in `handleWorkerResult` |
| `internal/io/pool/task.go` | `WriteFileTask.Execute()` calls `WriteFileAtomic`; `Result` carries `FilePath` |
| `internal/io/pool/result.go` | Added `FilePath` field to `Result` |
| `internal/io/pool/mock/filesystem.go` | Added `WriteFileAtomic` temp file + mutex-protected rename |
| `internal/editor/auto_save_test.go` | New test file: 9 tests covering debounce, generation filtering, stale results, atomic write, failure retry |