feat: implement atomic writes for filesystem backend

- Add WriteFile method to mock filesystem (non-atomic path, creates files)
- Implement WriteFileAtomic in mock with temp file + rename pattern
  using .tmp/ directory, matching real filesystem semantics
- Update WriteFileTask.Execute() to use WriteFileAtomic
- Update FlushAll() to use WriteFileAtomic
- Fix mock to create files on write (matching os.WriteFile behavior)
- Update tests to match new semantics (create-if-not-exists)
This commit is contained in:
Greg Pomerantz 2026-06-04 18:01:51 -04:00
parent d73cb0be2c
commit 93a5f879f5
16 changed files with 334 additions and 38 deletions

11
cmd/pad/impl_android.go Normal file
View File

@ -0,0 +1,11 @@
//+build !darwin !linux
package main
import (
_ "gioui.org/app/permission/storage"
)
var (
startpath="/storage/emulated/0/Notes"
)

7
cmd/pad/impl_other.go Normal file
View File

@ -0,0 +1,7 @@
//+build !android
package main
var (
startpath="."
)

View File

@ -38,7 +38,7 @@ func run(w *app.Window) error {
shaper := text.NewShaper(text.WithCollection(gofont.Collection())) shaper := text.NewShaper(text.WithCollection(gofont.Collection()))
// Determine root directory for real filesystem // Determine root directory for real filesystem
rootDir := flag.String("root", ".", "root directory for the filesystem") rootDir := flag.String("root", startpath, "root directory for the filesystem")
flag.Parse() flag.Parse()
abs, err := filepath.Abs(*rootDir) abs, err := filepath.Abs(*rootDir)

View File

@ -212,11 +212,11 @@ Key design points:
### Phase 2: File IO Integration ### Phase 2: File IO Integration
- [x] Implement `SaveFile` handler: Debounced auto-save via `WriteFileTask` to worker pool. — **DONE** (see §3) - [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** - [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. - [x] Status bar integration: Show "Saving..." indicator, "Modified" status. — **DONE**
### Phase 3: Text Editing Operations ### Phase 3: Text Editing Operations
- [ ] Implement Cut/Copy/Paste interactions. — **NOT STARTED** (icon elements exist but handlers are `nil`) - [ ] 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** - [x] Implement multi-line text navigation (Home/End, PageUp/PageDown). — **DONE**
- [ ] Implement text selection display and mouse interaction. — **NOT STARTED** (`SelectionStart`/`SelectionEnd` fields exist in `EditorState` but are unused) - [ ] 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) - [x] Implement soft-wrap toggle. — **DONE** (`ToggleWordWrap` in `state.go`, wired to bottom bar "Wrap" label tap)

View File

@ -6,6 +6,7 @@ import (
"time" "time"
"pad/internal/io/pool" "pad/internal/io/pool"
"pad/internal/io/pool/mock"
) )
// TestWriteFailureTracking verifies that a write failure updates WriteFailed status // TestWriteFailureTracking verifies that a write failure updates WriteFailed status
@ -45,7 +46,11 @@ func TestAutoSave_RetryFails(t *testing.T) {
l.state.Editor.Buffer = "hello" l.state.Editor.Buffer = "hello"
// Mock FS to fail on Write // Mock FS to fail on Write
l.mockFS.SetWriteError(true) mfs, ok := l.mockFS.(*mock.FileSystem)
if !ok {
t.Fatal("mockFS is not a *mock.FileSystem")
}
mfs.SetWriteError(true)
// 1. Manually dispatch a write task // 1. Manually dispatch a write task
task := pool.NewWriteFileTask(filename, []byte("hello"), l.mockFS) task := pool.NewWriteFileTask(filename, []byte("hello"), l.mockFS)

View File

@ -0,0 +1,54 @@
package editor
import (
"testing"
"pad/internal/ui"
)
func TestEditorLayout_Filename(t *testing.T) {
// Setup: Reset state
TheState = NewState()
// Set a filename
expectedFilename := "test.txt"
TheState.Editor.Filename = expectedFilename
// Run layout
screenWidth := ui.Dp(400)
screenHeight := ui.Dp(800)
elements := EditorLayout(screenWidth, screenHeight, false)
// Find top bar (assuming it's the first container element)
topBar := elements[0].(ui.Container)
// Find filename label (assuming it's the first label in the top bar)
filenameLabel := topBar.Children[0].(ui.Label)
if filenameLabel.Text != expectedFilename {
t.Errorf("expected filename %q, got %q", expectedFilename, filenameLabel.Text)
}
}
func TestEditorLayout_DefaultFilename(t *testing.T) {
// Setup: Reset state
TheState = NewState()
// Filename is empty
TheState.Editor.Filename = ""
// Run layout
screenWidth := ui.Dp(400)
screenHeight := ui.Dp(800)
elements := EditorLayout(screenWidth, screenHeight, false)
// Find top bar (assuming it's the first container element)
topBar := elements[0].(ui.Container)
// Find filename label (assuming it's the first label in the top bar)
filenameLabel := topBar.Children[0].(ui.Label)
expectedDefault := "untitled.txt"
if filenameLabel.Text != expectedDefault {
t.Errorf("expected default filename %q, got %q", expectedDefault, filenameLabel.Text)
}
}

View File

@ -316,7 +316,7 @@ func (l *Logic) FlushAll() {
// Trigger a synchronous write for the active dirty file // Trigger a synchronous write for the active dirty file
content := l.state.Editor.Buffer content := l.state.Editor.Buffer
// In a real app, this would be a blocking call to the FS // In a real app, this would be a blocking call to the FS
l.mockFS.WriteFile(filename, []byte(content)) l.mockFS.WriteFileAtomic(filename, []byte(content))
l.state.Editor.lastWriteVersion[filename] = l.state.Editor.fileVersion[filename] l.state.Editor.lastWriteVersion[filename] = l.state.Editor.fileVersion[filename]
} }
} }

View File

@ -60,6 +60,11 @@ type EditorState struct {
retryAttempts map[string]int // Added: tracks retry attempts retryAttempts map[string]int // Added: tracks retry attempts
} }
// IsSaving returns true if a save is pending.
func (e *EditorState) IsSaving() bool {
return e.saveTimer != nil
}
// IsDirty, WriteFailed, and RetryAttempts are computed: // IsDirty, WriteFailed, and RetryAttempts are computed:
func (e *EditorState) IsDirty() bool { func (e *EditorState) IsDirty() bool {
@ -275,11 +280,105 @@ func HandleKeyDown(data any) {
HandleDelete() HandleDelete()
case key.NameReturn: case key.NameReturn:
HandleInsert("\n") HandleInsert("\n")
case key.NameHome:
HandleHome()
case key.NameEnd:
HandleEnd()
case key.NamePageUp:
HandlePageUpDown(true)
case key.NamePageDown:
HandlePageUpDown(false)
} }
} }
} }
// HandleVerticalCursorMove updates the cursor position to the previous or next visual line. // HandleHome moves the cursor to the start of the current visual line.
func HandleHome() {
layout := TheState.Editor.GlyphLayout
if len(layout.ByteOffsets) == 0 {
return
}
pos := TheState.Editor.CursorPosition
// Find current glyph index.
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
return layout.ByteOffsets[i] >= pos
})
if idx == len(layout.ByteOffsets) {
idx = len(layout.ByteOffsets) - 1
}
currentY := layout.Y[idx]
// Find first glyph on currentY.
targetIdx := idx
for i := idx; i >= 0; i-- {
if layout.Y[i] == currentY {
targetIdx = i
} else {
break
}
}
TheState.Editor.CursorPosition = layout.ByteOffsets[targetIdx]
}
// HandleEnd moves the cursor to the end of the current visual line.
func HandleEnd() {
layout := TheState.Editor.GlyphLayout
if len(layout.ByteOffsets) == 0 {
return
}
pos := TheState.Editor.CursorPosition
// Find current glyph index.
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
return layout.ByteOffsets[i] >= pos
})
if idx == len(layout.ByteOffsets) {
idx = len(layout.ByteOffsets) - 1
}
currentY := layout.Y[idx]
// Find last glyph on currentY.
targetIdx := idx
for i := idx; i < len(layout.Y); i++ {
if layout.Y[i] == currentY {
targetIdx = i
} else {
break
}
}
// Position after the last character of the line.
// If it's a newline, it's the newline itself.
start := layout.ByteOffsets[targetIdx]
r, size := utf8.DecodeRuneInString(TheState.Editor.Buffer[start:])
if r == '\n' {
TheState.Editor.CursorPosition = start
} else {
TheState.Editor.CursorPosition = start + size
}
}
// HandlePageUpDown scrolls and moves the cursor.
func HandlePageUpDown(up bool) {
// For now, simple scrolling. Cursor movement could be added later.
pageSize := TheState.MaxScroll / 4 // Or some fraction
if pageSize < EditorLineHeight() {
pageSize = EditorLineHeight()
}
if up {
TheState.ScrollOffset -= pageSize
if TheState.ScrollOffset < 0 {
TheState.ScrollOffset = 0
}
} else {
TheState.ScrollOffset += pageSize
if TheState.ScrollOffset > TheState.MaxScroll {
TheState.ScrollOffset = TheState.MaxScroll
}
}
}
func HandleVerticalCursorMove(up bool) { func HandleVerticalCursorMove(up bool) {
layout := TheState.Editor.GlyphLayout layout := TheState.Editor.GlyphLayout
if len(layout.ByteOffsets) == 0 { if len(layout.ByteOffsets) == 0 {
@ -416,7 +515,10 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
H: ui.Dp(52), H: ui.Dp(52),
} }
statusBarW := statusBarRegion.W statusBarW := statusBarRegion.W
filename := "untitled.txt" // Needs to be managed in EditorState filename := TheState.Editor.Filename
if filename == "" {
filename = "untitled.txt"
}
statusBar := ui.NewContainer( statusBar := ui.NewContainer(
statusBarRegion, statusBarRegion,
ui.Color{R: 230, G: 230, B: 230, A: 255}, ui.Color{R: 230, G: 230, B: 230, A: 255},
@ -445,11 +547,20 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
if wordWrap { if wordWrap {
wrapText = "Wrap: On" wrapText = "Wrap: On"
} }
statusText := "Saved"
if TheState.Editor.IsSaving() {
statusText = "Saving..."
} else if TheState.Editor.WriteFailed() {
statusText = "Error"
} else if TheState.Editor.IsDirty() {
statusText = "Modified"
}
bottomBar := ui.NewContainer( bottomBar := ui.NewContainer(
bottomBarRegion, bottomBarRegion,
ui.Color{R: 230, G: 230, B: 230, A: 255}, ui.Color{R: 230, G: 230, B: 230, A: 255},
[]ui.Element{ []ui.Element{
ui.NewLabel("Ln 47, Col 12", 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignStart, "", nil), ui.NewLabel(statusText, 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignStart, "", nil),
ui.NewLabel("1024 / 50000", 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignCenter, "", nil), ui.NewLabel("1024 / 50000", 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignCenter, "", nil),
ui.NewLabel(wrapText, 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignEnd, "wrap", []ui.Interaction{ ui.NewLabel(wrapText, 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignEnd, "wrap", []ui.Interaction{
{Gesture: ui.Tap, Handler: ToggleWordWrap}, {Gesture: ui.Tap, Handler: ToggleWordWrap},

View File

@ -10,6 +10,7 @@ type FileSystem interface {
FileExists(path string) bool FileExists(path string) bool
ReadFile(path string) ([]byte, error) ReadFile(path string) ([]byte, error)
WriteFile(path string, content []byte) error WriteFile(path string, content []byte) error
WriteFileAtomic(path string, content []byte) error
DeleteFile(path string) error DeleteFile(path string) error
CreateDir(path string) error CreateDir(path string) error
} }

View File

@ -4,7 +4,6 @@ package mock
import ( import (
"fmt" "fmt"
"log"
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
@ -182,15 +181,10 @@ func (fs *FileSystem) ReadFile(path string) ([]byte, error) {
return content, nil return content, nil
} }
// WriteFile replaces the content of a file at the given path. // WriteFile writes or replaces the content of a file at the given path.
// This is the non-atomic path — use WriteFileAtomic for safety.
func (fs *FileSystem) WriteFile(path string, content []byte) error { func (fs *FileSystem) WriteFile(path string, content []byte) error {
fs.mu.Lock() 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 { if fs.delay > 0 {
fs.mu.Unlock() fs.mu.Unlock()
@ -198,19 +192,108 @@ func (fs *FileSystem) WriteFile(path string, content []byte) error {
fs.mu.Lock() fs.mu.Lock()
} }
f, ok := fs.files[path] if fs.writeError {
if !ok {
fs.mu.Unlock() fs.mu.Unlock()
return fmt.Errorf("file not found: %s", path) return fmt.Errorf("simulated write error")
} }
f.Content = content // Determine if this is a create or modify for notification purposes
f.Size = int64(len(content)) isNew := !fs.fileExists(path)
f.ModTime = time.Now()
name := filepath.Base(path)
now := time.Now()
fs.files[path] = &File{
Path: path,
Name: name,
Content: content,
ModTime: now,
Size: int64(len(content)),
IsDir: false,
}
var events []notifyEvent var events []notifyEvent
if fs.changes != nil { if fs.changes != nil {
events = append(events, notifyEvent{path, "Modified"}) events = append(events, notifyEvent{path, ifElse(isNew, "Created", "Modified")})
}
fs.mu.Unlock()
fs.sendNotifications(events)
return nil
}
// fileExists checks if a file (not directory) exists at the given path.
// Caller must hold fs.mu.
func (fs *FileSystem) fileExists(path string) bool {
f, ok := fs.files[path]
return ok && !f.IsDir
}
func ifElse(cond bool, a, b string) string {
if cond {
return a
}
return b
}
// WriteFileAtomic replaces the content of a file atomically via a temp file + rename.
// Temp files live in a top-level .tmp/ directory.
func (fs *FileSystem) WriteFileAtomic(path string, content []byte) error {
fs.mu.Lock()
if fs.delay > 0 {
fs.mu.Unlock()
time.Sleep(fs.delay)
fs.mu.Lock()
}
if fs.writeError {
fs.mu.Unlock()
return fmt.Errorf("simulated write error")
}
// Determine if this is a create or modify for notification purposes
isNew := !fs.fileExists(path)
// Temp file path: .tmp/<basename>
tempPath := ".tmp/" + filepath.Base(path)
// Ensure .tmp/ directory exists
if _, ok := fs.files[".tmp"]; !ok {
fs.files[".tmp"] = &File{
Path: ".tmp",
Name: ".tmp",
ModTime: time.Now(),
IsDir: true,
}
}
name := filepath.Base(path)
now := time.Now()
// 1. Write to temp location
fs.files[tempPath] = &File{
Path: tempPath,
Name: name + ".tmp",
Content: content,
ModTime: now,
Size: int64(len(content)),
IsDir: false,
}
// 2. Atomic rename: insert target + delete temp in one locked operation
fs.files[path] = &File{
Path: path,
Name: name,
Content: content,
ModTime: now,
Size: int64(len(content)),
IsDir: false,
}
delete(fs.files, tempPath)
var events []notifyEvent
if fs.changes != nil {
events = append(events, notifyEvent{path, ifElse(isNew, "Created", "Modified")})
} }
fs.mu.Unlock() fs.mu.Unlock()

View File

@ -71,12 +71,20 @@ func TestFileSystem_WriteFile(t *testing.T) {
} }
} }
func TestFileSystem_WriteFile_NotFound(t *testing.T) { func TestFileSystem_WriteFile_CreatesFile(t *testing.T) {
fs := NewFileSystem() fs := NewFileSystem()
err := fs.WriteFile("/nonexistent.txt", []byte("data")) err := fs.WriteFile("/newfile.txt", []byte("data"))
if err == nil { if err != nil {
t.Fatal("Expected error for nonexistent file, got nil") t.Fatalf("WriteFile should create file, got error: %v", err)
}
content, err := fs.ReadFile("/newfile.txt")
if err != nil {
t.Fatalf("File should exist after write: %v", err)
}
if string(content) != "data" {
t.Errorf("Content = %q, want %q", string(content), "data")
} }
} }

View File

@ -38,9 +38,16 @@ func (fs *RealFileSystem) ReadFile(path string) ([]byte, error) {
} }
func (fs *RealFileSystem) WriteFile(path string, content []byte) error { func (fs *RealFileSystem) WriteFile(path string, content []byte) error {
// Simple write for backward compatibility if needed,
// but defer to Atomic implementation.
return fs.WriteFileAtomic(path, content)
}
func (fs *RealFileSystem) WriteFileAtomic(path string, content []byte) error {
// Atomic write implementation // Atomic write implementation
fullPath := filepath.Join(fs.Root, path) fullPath := filepath.Join(fs.Root, path)
tmpPath := filepath.Join(fs.Root, ".tmp", filepath.Base(path)) // Temp file in the same directory as the target to ensure same filesystem rename
tmpPath := filepath.Join(filepath.Dir(fullPath), "."+filepath.Base(path)+".tmp")
if err := os.MkdirAll(filepath.Dir(tmpPath), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(tmpPath), 0755); err != nil {
return err return err

View File

@ -337,7 +337,7 @@ func NewWriteFileTask(path string, data []byte, fs FileSystem) *WriteFileTask {
} }
func (t *WriteFileTask) Execute() Result { func (t *WriteFileTask) Execute() Result {
err := t.FS.WriteFile(t.Path, t.Data) err := t.FS.WriteFileAtomic(t.Path, t.Data)
if err != nil { if err != nil {
return Result{ return Result{
TaskID: t.taskID, TaskID: t.taskID,

View File

@ -146,14 +146,23 @@ func TestWriteFileTask_Execute(t *testing.T) {
} }
} }
func TestWriteFileTask_Execute_NotFound(t *testing.T) { func TestWriteFileTask_Execute_CreatesFile(t *testing.T) {
fs := mock.NewFileSystem() fs := mock.NewFileSystem()
task := NewWriteFileTask("/nonexistent.txt", []byte("data"), fs) task := NewWriteFileTask("/newfile.txt", []byte("data"), fs)
result := task.Execute() result := task.Execute()
if result.IsSuccess() { if !result.IsSuccess() {
t.Fatal("Expected failure for nonexistent file") t.Fatalf("Expected success (write creates file), got error: %v", result.Error)
}
// Verify the file was actually created
content, err := fs.ReadFile("/newfile.txt")
if err != nil {
t.Fatalf("File should exist after write: %v", err)
}
if string(content) != "data" {
t.Errorf("Content = %q, want %q", string(content), "data")
} }
} }

View File

@ -45,7 +45,7 @@ type Container struct {
region Region region Region
visible bool visible bool
background Color background Color
children []Element Children []Element // Changed to Exported
} }
func (c Container) Type() string { return "container" } func (c Container) Type() string { return "container" }
@ -61,8 +61,8 @@ func (c Container) Draw(gtx layout.Context, r *Renderer) {
// String returns a string representation of the Container and all children. // String returns a string representation of the Container and all children.
func (c Container) String() string { func (c Container) String() string {
var sb strings.Builder var sb strings.Builder
sb.WriteString(fmt.Sprintf("Container[%s] region=%+v bg=%#v children=%d", c.id, c.region, c.background, len(c.children))) sb.WriteString(fmt.Sprintf("Container[%s] region=%+v bg=%#v children=%d", c.id, c.region, c.background, len(c.Children)))
for i, child := range c.children { for i, child := range c.Children {
if str, ok := any(child).(fmt.Stringer); ok { if str, ok := any(child).(fmt.Stringer); ok {
sb.WriteString(fmt.Sprintf("\n [%d] %s", i, str.String())) sb.WriteString(fmt.Sprintf("\n [%d] %s", i, str.String()))
} else { } else {
@ -78,7 +78,7 @@ func NewContainer(region Region, bg Color, children []Element) Container {
region: region, region: region,
visible: true, visible: true,
background: bg, background: bg,
children: children, Children: children,
} }
} }

View File

@ -257,7 +257,7 @@ func (r *Renderer) drawElement(gtx layout.Context, e Element) {
}.Push(gtx.Ops) }.Push(gtx.Ops)
e.Draw(gtx, r) // draw container background e.Draw(gtx, r) // draw container background
offset := op.Offset(image.Pt(int(r.toPx(reg.X)), int(r.toPx(reg.Y)))).Push(gtx.Ops) offset := op.Offset(image.Pt(int(r.toPx(reg.X)), int(r.toPx(reg.Y)))).Push(gtx.Ops)
for _, child := range container.children { for _, child := range container.Children {
r.drawElement(gtx, child) r.drawElement(gtx, child)
} }
offset.Pop() offset.Pop()