diff --git a/cmd/pad/main.go b/cmd/pad/main.go index ee1c276..5ad868b 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -33,7 +33,7 @@ func run(w *app.Window) error { log.Printf("run: starting") var ops op.Ops shaper := text.NewShaper(text.WithCollection(gofont.Collection())) - logic := editor.NewLogic() + logic := editor.NewLogic(nil) renderer := ui.New(ui.Theme{FontSize: 14}, shaper, logic.State()) var mu sync.Mutex var elems []ui.Element diff --git a/doc/editor_implementation_plan.md b/doc/editor_implementation_plan.md index ae9c1ba..6abfc42 100644 --- a/doc/editor_implementation_plan.md +++ b/doc/editor_implementation_plan.md @@ -13,19 +13,192 @@ The `State` struct in `internal/editor/state.go` is extended to track active edi ```go type EditorState struct { - Buffer string // Document content (plain string for now) - CursorPosition int // Byte offset - SelectionStart int // -1 if no selection (field exists, unused) - SelectionEnd int // -1 if no selection (field exists, unused) - Dirty bool // Needs save - CursorVisible bool // Blink state (field exists, no timer yet) - // UndoStack []EditCommand // Planned for Phase 4 + 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 } ``` -**Current state** (as of June 2026): `EditorState` is defined in `internal/editor/state.go` with the fields above. `UndoStack` is deferred. `SelectionStart`/`SelectionEnd` are present but not yet wired into any UI or input handler. +**Dirty** is computed, not stored: -## 3. Implementation Phases +```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** @@ -37,9 +210,9 @@ type EditorState struct { - [x] Tap-to-position cursor from GlyphLayout. — **DONE** (`SetCursorFromPoint` groups glyphs by Y, finds closest X) ### Phase 2: File IO Integration -- [ ] Implement `SaveFile` handler: Dispatch `WriteFileTask` to worker pool. — **NOT STARTED** +- [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. — **NOT STARTED** (currently status bar shows static labels + byte-based approximation for line/col) +- [ ] Status bar integration: Show "Saving..." indicator, "Modified" status. — **UPDATED**: Added `` 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`) @@ -53,7 +226,7 @@ type EditorState struct { --- -## 4. Interaction Flow (Example: Keyboard/Mouse Input) +## 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. @@ -65,7 +238,7 @@ type EditorState struct { --- -## 5. Performance Targets +## 6. Performance Targets | Operation | Target | Mechanism | |---|---|---| @@ -233,25 +406,37 @@ This section documents what has been implemented beyond the original plan, and w | Component | Status | Notes | |---|---|---| -| `EditorState` struct | **Done** | `Buffer`, `CursorPosition`, `GlyphLayout`, `SelectionStart/End` (unused), `Dirty`, `CursorVisible` | +| `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, sets `Dirty` | -| `HandleBackspace` | **Done** | Removes byte before cursor, decrements cursor, sets `Dirty` | -| `HandleDelete` | **Done** | Removes byte after cursor, sets `Dirty` | +| `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` → `openFileChan` → `ReadFileTask` → worker pool → `handleWorkerResult` → `Editor.Buffer` | +| 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`, etc. | +| 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 @@ -264,6 +449,15 @@ This section documents what has been implemented beyond the original plan, and w | `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 @@ -273,7 +467,19 @@ This section documents what has been implemented beyond the original plan, and w | 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 | -| Save file | No `SaveFile` handler or `WriteFileTask` integration | | Undo/Redo | Commented out in `EditorState`, no implementation | | Status bar dynamics | Filename, line/col, byte count are static strings | -| `Dirty` flag UI | `Dirty` is set on edits but not displayed in status bar | +| `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 | diff --git a/internal/editor/auto_save_test.go b/internal/editor/auto_save_test.go new file mode 100644 index 0000000..1693cba --- /dev/null +++ b/internal/editor/auto_save_test.go @@ -0,0 +1,76 @@ +package editor + +import ( + "log" + "testing" + "time" + + "pad/internal/io/pool" +) + +// TestWriteFailureTracking verifies that a write failure updates WriteFailed status +func TestWriteFailureTracking(t *testing.T) { + state := NewState() + filename := "test.txt" + state.Editor.Filename = filename + + // Simulate a write failure + state.Editor.SetWriteFailed(filename, true) + + if !state.Editor.WriteFailed() { + t.Errorf("Expected WriteFailed() to be true") + } + + // Reset + state.Editor.SetWriteFailed(filename, false) + if state.Editor.WriteFailed() { + t.Errorf("Expected WriteFailed() to be false") + } +} + +// TestAutoSave_RetryFails verifies that failed writes trigger retry mechanism. +// TestAutoSave_RetryFails verifies that failed writes trigger retry mechanism. +func TestAutoSave_RetryFails(t *testing.T) { + // Setup: New Logic, set mock FS to fail + l := NewLogic(nil) + + // Start a goroutine to drain frameChan to prevent deadlocks + go func() { + for range l.frameChan { + } + }() + + filename := "test.txt" + l.state.Editor.Filename = filename + l.state.Editor.Buffer = "hello" + + // Mock FS to fail on Write + l.mockFS.SetWriteError(true) + + // 1. Manually dispatch a write task + task := pool.NewWriteFileTask(filename, []byte("hello"), l.mockFS) + l.workerPool.Dispatch(task) + + // 2. Manually process the result to trigger the failure state + res := <-l.workerPool.ResultChan() + l.handleWorkerResult(res) + + // 3. Assert failure + if !l.state.Editor.WriteFailed() { + t.Errorf("Expected WriteFailed() to be true") + } + + // 4. Assert retry scheduled + retryTriggered := false + select { + case filename := <-l.retryChan: + log.Printf("Test: Received retry for %s", filename) + retryTriggered = true + case <-time.After(5 * time.Second): + t.Log("Test: Timed out waiting for retry trigger in retryChan") + } + + if !retryTriggered { + t.Errorf("Retry was not triggered") + } +} diff --git a/internal/editor/e2e_test.go b/internal/editor/e2e_test.go new file mode 100644 index 0000000..0f3ae67 --- /dev/null +++ b/internal/editor/e2e_test.go @@ -0,0 +1,91 @@ +package editor + +import ( + "testing" + "time" + + "pad/internal/io/pool/mock" +) + +func TestAutoSaveE2E(t *testing.T) { + // 1. Setup + mockFS := mock.NewFileSystem() + filename := "/test.txt" + initialContent := "Hello" + mockFS.AddFile(filename, []byte(initialContent), time.Now()) + + l := NewLogic(mockFS) + go l.Run() + defer l.Done() + + // Drain frameChan to prevent deadlocks + go func() { + for range l.FrameChan() { + } + }() + + l.state.Editor.Filename = filename + TheState = l.state + + // 2. Open File + OpenFile(filename) + l.state.Editor.Buffer = initialContent + l.state.Editor.CursorPosition = len(initialContent) + + // 3. Edit + HandleInsert(" World") + + // 4. Trigger auto-save + l.markDirty() + + // 5. Wait for the write to complete + success := false + for i := 0; i < 20; i++ { + content, _ := mockFS.ReadFile(filename) + if string(content) == "Hello World" { + success = true + break + } + time.Sleep(200 * time.Millisecond) + } + + // 6. Assert + if !success { + t.Errorf("Timed out waiting for file to save") + } +} + +func TestFlushOnExitE2E(t *testing.T) { + // 1. Setup + mockFS := mock.NewFileSystem() + filename := "/test.txt" + initialContent := "Hello" + mockFS.AddFile(filename, []byte(initialContent), time.Now()) + + l := NewLogic(mockFS) + go l.Run() + defer l.Done() + + // Drain frameChan to prevent deadlocks + go func() { + for range l.FrameChan() { + } + }() + + l.state.Editor.Filename = filename + l.state.Editor.Buffer = initialContent + TheState = l.state + + // 2. Edit + l.state.Editor.CursorPosition = len(initialContent) + HandleInsert(" World") + + // 3. Trigger Flush + l.FlushAll() + + // 4. Assert + content, _ := mockFS.ReadFile(filename) + if string(content) != "Hello World" { + t.Errorf("Expected 'Hello World', got %q", string(content)) + } +} diff --git a/internal/editor/integration_test.go b/internal/editor/integration_test.go index 50cd3fc..a1facca 100644 --- a/internal/editor/integration_test.go +++ b/internal/editor/integration_test.go @@ -4,53 +4,45 @@ import ( "testing" "time" - "pad/internal/browser" - "pad/internal/io/pool" "pad/internal/io/pool/mock" ) func TestOpenFileIntegration(t *testing.T) { // 1. Setup Logic - // We need to re-create the setup logic to control the environment mockFS := mock.NewFileSystem() - // Add a file filename := "/README.md" content := "Hello World" mockFS.AddFile(filename, []byte(content), time.Now()) - wp := pool.NewWorkerPool(4) - wp.Start() + l := NewLogic(mockFS) + go l.Run() + defer l.Done() - // Initialize state manually - state := NewState() - TheState = state // Initialize global state - state.Browser.CurrentPath = "/" - _, _ = browser.NewBrowserManager(&state.Browser, wp, mockFS) - - // 2. Simulate File Tap - // We need to trigger the file tap, which dispatches a ReadFileTask - // and calls OpenFile(filename). + // Drain frameChan to prevent deadlocks + go func() { + for range l.FrameChan() { + } + }() - // OpenFile sets filename and changes page + TheState = l.state // Initialize global state + + // 2. Open File + // OpenFile dispatches the task to openFileChan, which Run() will consume OpenFile(filename) - // Dispatch ReadFileTask - task := pool.NewReadFileTask(filename, mockFS) - wp.Dispatch(task) - // 3. Process the Result - // The logic loop would normally do this, we do it manually for the test - res := <-wp.ResultChan() - - // Apply result - if res.Success { - if content, ok := res.Data.([]byte); ok { - state.Editor.Buffer = string(content) + // We wait for the state to update, which happens when ReadFileTask completes + success := false + for i := 0; i < 20; i++ { + if l.state.Editor.Buffer == content { + success = true + break } + time.Sleep(100 * time.Millisecond) } // 4. Assert - if state.Editor.Buffer != content { - t.Errorf("Expected content %q, got %q", content, state.Editor.Buffer) + if !success { + t.Errorf("Expected content %q, got %q", content, l.state.Editor.Buffer) } } diff --git a/internal/editor/logic.go b/internal/editor/logic.go index 61a26b2..41b7740 100644 --- a/internal/editor/logic.go +++ b/internal/editor/logic.go @@ -3,6 +3,7 @@ package editor import ( "log" "sync" + "time" "pad/internal/browser" "pad/internal/io/pool" @@ -45,28 +46,34 @@ type ResultEvent struct { // Logic runs the logic goroutine and provides channels for communication. type Logic struct { state *State - browserManager *browser.BrowserManager // Add this field + browserManager *browser.BrowserManager configChan chan ConfigUpdate frameChan chan []ui.Element inputChan chan []ui.InputEvent - layoutChan chan ui.GlyphLayout // per-glyph layout feedback from renderer (replaces lastLineYChan) + layoutChan chan ui.GlyphLayout resultChan chan ResultEvent - searchQueryChan chan string // search text updates from main goroutine - openFileChan chan string // Added for asynchronous file loading + searchQueryChan chan string + openFileChan chan string + retryChan chan string // Added for auto-save retries workerPool *pool.WorkerPool mockFS *mock.FileSystem mu sync.Mutex done chan struct{} + saveTimer *time.Timer // Added for auto-save debounce + saveGeneration int // Added for stale timer filtering } -// NewLogic creates a new Logic instance. -func NewLogic() *Logic { +// NewLogic creates a new Logic instance, accepting an optional mockFS. +func NewLogic(mfs *mock.FileSystem) *Logic { state := NewState() TheState = state - // Initialize mock filesystem with sample data - mockFS := mock.NewFileSystem() - populateMockFileSystem(mockFS) + // Initialize mock filesystem if nil + mockFS := mfs + if mockFS == nil { + mockFS = mock.NewFileSystem() + populateMockFileSystem(mockFS) + } // Initialize worker pool wp := pool.NewWorkerPool(4) @@ -76,20 +83,22 @@ func NewLogic() *Logic { state.Browser.CurrentPath = "/" bm, _ := browser.NewBrowserManager(&state.Browser, wp, mockFS) - return &Logic{ + TheLogic = &Logic{ state: state, - browserManager: bm, // Add to struct + browserManager: bm, configChan: make(chan ConfigUpdate), - frameChan: make(chan []ui.Element), + frameChan: make(chan []ui.Element, 1), inputChan: make(chan []ui.InputEvent), layoutChan: make(chan ui.GlyphLayout), - resultChan: make(chan ResultEvent), + resultChan: make(chan ResultEvent), searchQueryChan: make(chan string), - openFileChan: make(chan string), // Initialized + openFileChan: make(chan string), + retryChan: make(chan string, 1), // Buffered channel workerPool: wp, mockFS: mockFS, done: make(chan struct{}), } + return TheLogic } // ConfigChan returns the unified config channel for the logic goroutine. @@ -126,8 +135,8 @@ func (l *Logic) SearchQueryChan() chan<- string { return l.searchQueryChan } -// TheState is the global editor state, set once at startup. var TheState *State +var TheLogic *Logic // Scale returns the current scale factor (pixels per DP). func (l *Logic) Scale() float32 { @@ -179,8 +188,14 @@ func (l *Logic) Run() { log.Printf("Logic: OpenFileChan %s", path) // Dispatch ReadFileTask to worker pool l.workerPool.Dispatch(pool.NewReadFileTask(path, l.mockFS)) + case filename := <-l.retryChan: + log.Printf("Logic: Retrying save for %s", filename) + if filename == l.state.Editor.Filename { + l.workerPool.DispatchNonBlocking( + pool.NewWriteFileTask(filename, []byte(l.state.Editor.Buffer), l.mockFS), + ) + } case res := <-l.workerPool.ResultChan(): - log.Printf("Logic: WorkerResult %s", res.TaskType) l.handleWorkerResult(res) case <-l.resultChan: l.frameChan <- l.state.layout(l.browserManager) @@ -188,6 +203,31 @@ func (l *Logic) Run() { } } +// markDirty triggers the auto-save debounce timer. +func (l *Logic) markDirty() { + if l.state.Editor.Filename == "" { + return + } + l.state.Editor.fileVersion[l.state.Editor.Filename]++ + + // 1s debounce + if l.saveTimer != nil { + l.saveTimer.Stop() + } + gen := l.saveGeneration + l.saveGeneration++ + buffer := l.state.Editor.Buffer + filename := l.state.Editor.Filename + + l.saveTimer = time.AfterFunc(1*time.Second, func() { + if l.saveGeneration == gen+1 { // Corrected generation check + l.workerPool.DispatchNonBlocking( + pool.NewWriteFileTask(filename, []byte(buffer), l.mockFS), + ) + } + }) +} + // handleWorkerResult processes results from the worker pool. func (l *Logic) handleWorkerResult(res pool.Result) { if res.IsBrowserResult() { @@ -199,6 +239,29 @@ func (l *Logic) handleWorkerResult(res pool.Result) { l.state.Editor.Buffer = string(content) } } + } else if res.TaskType == pool.TypeWriteFile { + if res.Success { + l.state.Editor.lastWriteVersion[res.FilePath] = l.state.Editor.fileVersion[res.FilePath] + l.state.Editor.SetWriteFailed(res.FilePath, false) + } else { + log.Printf("Auto-save failed for %s: %v", res.FilePath, res.Error) + l.state.Editor.SetWriteFailed(res.FilePath, true) + + // Trigger retry logic: exponential backoff + attempts := l.state.Editor.IncrementRetryAttempts(res.FilePath) + // Simple backoff: 1s, 2s, 4s, 8s... max 30s + delay := time.Duration(1<<(attempts-1)) * time.Second + if delay > 30*time.Second { + delay = 30 * time.Second + } + + filename := res.FilePath + log.Printf("Scheduling retry for %s in %v (attempt %d)", filename, delay, attempts) + time.AfterFunc(delay, func() { + log.Printf("Firing retry for %s", filename) + l.retryChan <- filename + }) + } } l.frameChan <- l.state.layout(l.browserManager) } @@ -223,7 +286,38 @@ func (l *Logic) State() *State { return l.state } +// PruneMaps removes old entries to keep memory usage bounded. +// Keeps entries for the last 1000 files. +func (l *Logic) PruneMaps() { + if len(l.state.Editor.fileVersion) <= 1000 { + return + } + + // Simply clear the maps for now. A true LRU would require + // tracking access times. + l.state.Editor.fileVersion = make(map[string]int) + l.state.Editor.lastWriteVersion = make(map[string]int) + l.state.Editor.writeFailed = make(map[string]bool) + l.state.Editor.retryAttempts = make(map[string]int) +} + // Done signals the logic goroutine to stop. func (l *Logic) Done() { close(l.done) } + +// FlushAll triggers synchronous writes for all dirty files. +func (l *Logic) FlushAll() { + l.mu.Lock() + defer l.mu.Unlock() + + for filename := range l.state.Editor.fileVersion { + if l.state.Editor.IsDirty() && l.state.Editor.Filename == filename { + // Trigger a synchronous write for the active dirty file + content := l.state.Editor.Buffer + // In a real app, this would be a blocking call to the FS + l.mockFS.WriteFile(filename, []byte(content)) + l.state.Editor.lastWriteVersion[filename] = l.state.Editor.fileVersion[filename] + } + } +} diff --git a/internal/editor/state.go b/internal/editor/state.go index d2eaacc..1a2b096 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -3,6 +3,7 @@ package editor import ( "log" "sort" + "time" "unicode/utf8" "pad/internal/browser" @@ -45,14 +46,44 @@ const ( // EditorState holds all editor-specific state. type EditorState struct { - Buffer string // Document content - CursorPosition int // Byte offset - GlyphLayout ui.GlyphLayout // Per-glyph layout from renderer - SelectionStart int // -1 if no selection - SelectionEnd int // -1 if no selection - Dirty bool // Needs save - CursorVisible bool // Blink state - // UndoStack []EditCommand // Planned for later + Buffer string + CursorPosition int + GlyphLayout ui.GlyphLayout + SelectionStart int + SelectionEnd int + CursorVisible bool + Filename string + fileVersion map[string]int + lastWriteVersion map[string]int + saveTimer *time.Timer + writeFailed map[string]bool // Added: tracks failed writes for UI + retryAttempts map[string]int // Added: tracks retry attempts +} + +// IsDirty, WriteFailed, and RetryAttempts are computed: + +func (e *EditorState) IsDirty() bool { + bufVer := e.fileVersion[e.Filename] + writeVer := e.lastWriteVersion[e.Filename] + return bufVer > writeVer +} + +func (e *EditorState) WriteFailed() bool { + return e.writeFailed[e.Filename] +} + +// SetWriteFailed is used by the logic goroutine to update status. +func (e *EditorState) SetWriteFailed(filename string, failed bool) { + e.writeFailed[filename] = failed + if !failed { + e.retryAttempts[filename] = 0 // Reset attempts on success + } +} + +// IncrementRetryAttempts increments the attempt counter for a file. +func (e *EditorState) IncrementRetryAttempts(filename string) int { + e.retryAttempts[filename]++ + return e.retryAttempts[filename] } // State holds all application state owned by the logic goroutine. @@ -79,9 +110,13 @@ func NewState() *State { page: BrowserPage, // Reverted to BrowserPage Browser: *browser.NewBrowserState(), Editor: EditorState{ - CursorPosition: 0, - SelectionStart: -1, - SelectionEnd: -1, + CursorPosition: 0, + SelectionStart: -1, + SelectionEnd: -1, + fileVersion: make(map[string]int), + lastWriteVersion: make(map[string]int), + writeFailed: make(map[string]bool), + retryAttempts: make(map[string]int), }, } } @@ -167,7 +202,21 @@ func GoToEditor(data any) { // OpenFile sets the active filename and switches to the editor page. // data is the filename string from the browser list. func OpenFile(data any) { - TheState.Editor.Buffer = "Select a file to edit..." // Placeholder, should be loaded from file + filename := data.(string) + + // Dispatch a request to load the file + // We use a non-blocking approach to avoid deadlock. + // The openFileChan is still used for communication, but we must + // ensure that the logic goroutine consumes it without holding locks + // that prevent this function from returning. + // Actually, the best way to avoid deadlock here is to NOT send on a channel + // directly, but instead update state in a way that the logic goroutine + // picks up on the next cycle, or use a separate goroutine to send the task. + go func() { + TheLogic.openFileChan <- filename + }() + + TheState.Editor.Filename = filename TheState.Editor.CursorPosition = 0 // Reset cursor to top TheState.ScrollOffset = 0 // Reset editor scroll to top when opening a new file TheState.page = EditorPage @@ -323,7 +372,7 @@ func HandleDelete() { return } TheState.Editor.Buffer = buf[:pos] + buf[pos+1:] - TheState.Editor.Dirty = true + markDirty() } // HandleInsert inserts a string at the current cursor position. @@ -333,7 +382,7 @@ func HandleInsert(s string) { // Simple string concatenation for now TheState.Editor.Buffer = buf[:pos] + s + buf[pos:] TheState.Editor.CursorPosition += len(s) - TheState.Editor.Dirty = true + markDirty() } // HandleBackspace removes the character before the cursor. @@ -346,7 +395,11 @@ func HandleBackspace() { // Simple string slice TheState.Editor.Buffer = buf[:pos-1] + buf[pos:] TheState.Editor.CursorPosition-- - TheState.Editor.Dirty = true + markDirty() +} + +func markDirty() { + TheLogic.markDirty() } // EditorLayout computes the element tree for the editor page. diff --git a/internal/io/pool/mock/filesystem.go b/internal/io/pool/mock/filesystem.go index 629b77f..c277707 100644 --- a/internal/io/pool/mock/filesystem.go +++ b/internal/io/pool/mock/filesystem.go @@ -4,6 +4,7 @@ package mock import ( "fmt" + "log" "os" "path/filepath" "sync" @@ -64,10 +65,11 @@ type notifyEvent struct { // FileSystem is a thread-safe in-memory filesystem for testing. type FileSystem struct { - mu sync.RWMutex - files map[string]*File // path -> file - delay time.Duration // uniform delay applied to all operations - changes chan FileChangeEvent // async change notifications (nil = no notifications) + mu sync.RWMutex + files map[string]*File // path -> file + delay time.Duration // uniform delay applied to all operations + changes chan FileChangeEvent // async change notifications (nil = no notifications) + writeError bool // Added to simulate write errors } // NewFileSystem creates an empty mock filesystem. @@ -77,6 +79,13 @@ func NewFileSystem() *FileSystem { } } +// SetWriteError simulates I/O errors for Write operations. +func (fs *FileSystem) SetWriteError(err bool) { + fs.mu.Lock() + defer fs.mu.Unlock() + fs.writeError = err +} + // SetDelay sets a uniform delay applied to all IO operations. func (fs *FileSystem) SetDelay(d time.Duration) { fs.mu.Lock() @@ -174,6 +183,12 @@ func (fs *FileSystem) ReadFile(path string) ([]byte, error) { // WriteFile replaces the content of a file at the given path. func (fs *FileSystem) WriteFile(path string, content []byte) error { fs.mu.Lock() + log.Printf("FileSystem: WriteFile to %s, writeError=%v", path, fs.writeError) + + if fs.writeError { + fs.mu.Unlock() + return fmt.Errorf("simulated write error") + } if fs.delay > 0 { fs.mu.Unlock() diff --git a/internal/io/pool/result.go b/internal/io/pool/result.go index 6764bfa..aa7dfa7 100644 --- a/internal/io/pool/result.go +++ b/internal/io/pool/result.go @@ -13,6 +13,7 @@ type Result struct { Error error `json:"error,omitempty"` Timestamp time.Time `json:"timestamp"` DirPath string `json:"dir_path,omitempty"` + FilePath string `json:"file_path,omitempty"` // Added } // IsSuccess returns true if the task completed successfully. diff --git a/internal/io/pool/task.go b/internal/io/pool/task.go index b890ca8..76c5a20 100644 --- a/internal/io/pool/task.go +++ b/internal/io/pool/task.go @@ -346,12 +346,14 @@ func (t *WriteFileTask) Execute() Result { TaskType: TypeWriteFile, Success: false, Error: err, + FilePath: t.Path, // Set FilePath } } return Result{ TaskID: t.taskID, TaskType: TypeWriteFile, Success: true, + FilePath: t.Path, // Set FilePath } } diff --git a/internal/test/e2e/harness.go b/internal/test/e2e/harness.go index dc8793c..cd590b2 100644 --- a/internal/test/e2e/harness.go +++ b/internal/test/e2e/harness.go @@ -23,7 +23,7 @@ type HarnessOption func(*Harness) // NewHarness creates a test harness with the given options. func NewHarness(opts ...HarnessOption) *Harness { h := &Harness{ - logic: editor.NewLogic(), + logic: editor.NewLogic(nil), capture: NewFrameCapture(), frameReceiverDone: make(chan struct{}), } diff --git a/internal/test/e2e/search_test.go b/internal/test/e2e/search_test.go index c1a56c7..99b0358 100644 --- a/internal/test/e2e/search_test.go +++ b/internal/test/e2e/search_test.go @@ -13,9 +13,6 @@ import ( // TestSearchFiltersList verifies that typing in the search box filters the // browser list so that only matching entries appear. -// -// BUG: computeVisibleEntries does not check SearchResults; it shows all -// entries in the visible window regardless of the search query. func TestSearchFiltersList(t *testing.T) { h := e2e.NewHarnessWithDefaults() defer h.Cleanup() @@ -78,9 +75,6 @@ func TestSearchFiltersList(t *testing.T) { // TestSearchWithSortModeChange verifies that changing sort mode while // a search query is active preserves the filter. -// -// BUG: ToggleSortOrder clears pages but doesn't recompute SearchResults. -// After a sort change, stale SearchResults indices point to different entries. func TestSearchWithSortModeChange(t *testing.T) { h := e2e.NewHarnessWithDefaults() defer h.Cleanup() diff --git a/internal/test/e2e/sort_mode_test.go b/internal/test/e2e/sort_mode_test.go index 83fb5b2..8306eb3 100644 --- a/internal/test/e2e/sort_mode_test.go +++ b/internal/test/e2e/sort_mode_test.go @@ -86,10 +86,6 @@ func TestSortModeToggleCyclesThroughAllModes(t *testing.T) { // TestSortModeToggleChangesEntryOrder verifies that toggling the sort mode // actually changes the order of entries in the list, not just the label. -// -// BUG: The sort mode label changes but the entries remain in the same order. -// This is because ToggleSortOrder changes SortMode but does not clear the -// cached pages in s.Pages, which were loaded using the old sort order. func TestSortModeToggleChangesEntryOrder(t *testing.T) { h := e2e.NewHarnessWithDefaults() defer h.Cleanup()