Fix deadlock when opening files and update E2E tests

This commit is contained in:
Greg Pomerantz 2026-06-04 13:06:30 -04:00
parent d06f8af0f7
commit ca828569f7
13 changed files with 617 additions and 97 deletions

View File

@ -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

View File

@ -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 `<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`)
@ -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 |

View File

@ -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")
}
}

View File

@ -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))
}
}

View File

@ -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)
// Drain frameChan to prevent deadlocks
go func() {
for range l.FrameChan() {
}
}()
// 2. Simulate File Tap
// We need to trigger the file tap, which dispatches a ReadFileTask
// and calls OpenFile(filename).
TheState = l.state // Initialize global state
// OpenFile sets filename and changes page
// 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)
}
}

View File

@ -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]
}
}
}

View File

@ -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.

View File

@ -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()

View File

@ -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.

View File

@ -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
}
}

View File

@ -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{}),
}

View File

@ -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()

View File

@ -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()