Pad/internal/io/pool/mock/filesystem.go
Greg Pomerantz d8b5bc704b Handle drags: 1:1 finger tracking with cross-flip; fix caret/taps on empty lines
Selection handles now track the finger 1:1 (anchor grab point + displacement)
instead of snapping by whole lines, and crossing the opposite handle flips
the selection (native behaviour) instead of clearing it.

Caret and tap/handle line resolution use VisualLineStarts instead of the
min-Y baseline: the window's first visual line may be an empty line with no
recorded glyphs, which used to draw boundary carets one line too low per
leading empty line and land taps/dragged handles one line below the finger.
New exported ui.CaretPoint centralises byte->insertion-point mapping.

The off-screen caret no longer clamps to the window edge: EditorLayout ships
the true (possibly negative / past-end) window-relative cursor and the
renderer skips the caret when the cursor is outside the shaped window, so
scrolling past the caret no longer makes it jump onto the top/bottom line.

IME/router replay fixes: key.FocusCmd is issued only on a focus transition
(a per-frame no-op still takes the immediate-command path and re-queues all
pointer events), and the key.SelectionCmd IME sync is deferred while a
handle drag is in progress (each push re-injected the drag into every
gesture). Handle drags forward only Grabbed events; a tap inside a handle
grab box is a no-op.

Also: key.FocusEvent no longer logs as unexpected in main; dead code removed
(worker taskWrapper, browser applyXxxResult stubs, scrollIndex, mock_setup
sortModeKey/lineSpan helpers); mock FileSystem.ListPaths prefix match uses
strings.HasPrefix; build scripts run the new scripts/check.sh static gate
(go vet + staticcheck). Tests: caret_point_test, touch_selection updates
(flip/empty-line cases), off-window caret e2e, selection drag e2e grab step.
2026-08-19 22:34:12 -04:00

559 lines
12 KiB
Go

// Package mock provides a configurable mock filesystem for testing
// the IO worker pool without touching the real disk.
package mock
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"pad/internal/io/pool/types"
)
// DirEntry mimics fs.DirEntry behavior for the mock.
type DirEntry struct {
name string
isDir bool
modTime time.Time
size int64
}
func (d DirEntry) Name() string { return d.name }
func (d DirEntry) IsDir() bool { return d.isDir }
func (d DirEntry) Info() (os.FileInfo, error) {
return &mockFileInfo{name: d.name, isDir: d.isDir, modTime: d.modTime, size: d.size}, nil
}
// File represents a file or directory in the mock filesystem.
type File struct {
Path string
Name string
Content []byte
ModTime time.Time
Size int64
IsDir bool
}
// mockFileInfo implements os.FileInfo for the mock.
type mockFileInfo struct {
name string
isDir bool
modTime time.Time
size int64
}
func (fi *mockFileInfo) Name() string { return fi.name }
func (fi *mockFileInfo) Size() int64 { return fi.size }
func (fi *mockFileInfo) Mode() os.FileMode { return 0644 }
func (fi *mockFileInfo) ModTime() time.Time { return fi.modTime }
func (fi *mockFileInfo) IsDir() bool { return fi.isDir }
func (fi *mockFileInfo) Sys() any { return nil }
// FileChangeEvent represents an external filesystem change.
type FileChangeEvent struct {
Path string
EventType string // "Created", "Modified", "Deleted"
Time time.Time
}
// notifyEvent is a pending notification collected while holding the lock.
type notifyEvent struct {
path string
eventType string
}
// 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)
writeError bool // Added to simulate write errors
}
// NewFileSystem creates an empty mock filesystem.
func NewFileSystem() *FileSystem {
return &FileSystem{
files: make(map[string]*File),
}
}
// 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()
defer fs.mu.Unlock()
fs.delay = d
}
// SetChangeEvents sets the channel for async change notifications.
// Pass nil to disable notifications.
func (fs *FileSystem) SetChangeEvents(ch chan FileChangeEvent) {
fs.mu.Lock()
defer fs.mu.Unlock()
fs.changes = ch
}
// sendNotifications sends any pending notification events.
// Caller must NOT hold the mutex.
func (fs *FileSystem) sendNotifications(events []notifyEvent) {
fs.mu.RLock()
ch := fs.changes
fs.mu.RUnlock()
if ch == nil {
return
}
for _, e := range events {
select {
case ch <- FileChangeEvent{
Path: e.path,
EventType: e.eventType,
Time: time.Now(),
}:
default:
// Drop event if channel is full (non-blocking)
}
}
}
// CreateFile creates a file with the given content at the specified path.
func (fs *FileSystem) CreateFile(path string, content []byte) error {
fs.mu.Lock()
if fs.delay > 0 {
fs.mu.Unlock()
time.Sleep(fs.delay)
fs.mu.Lock()
}
name := filepath.Base(path)
now := time.Now()
f := &File{
Path: path,
Name: name,
Content: content,
ModTime: now,
Size: int64(len(content)),
IsDir: false,
}
fs.files[path] = f
// Collect notification while holding the lock
var events []notifyEvent
if fs.changes != nil {
events = append(events, notifyEvent{path, "Created"})
}
fs.mu.Unlock()
// Send notifications outside the lock (avoids deadlock)
fs.sendNotifications(events)
return nil
}
// ReadFile returns the content of a file at the given path.
func (fs *FileSystem) ReadFile(path string) ([]byte, error) {
fs.mu.Lock()
if fs.delay > 0 {
fs.mu.Unlock()
time.Sleep(fs.delay)
fs.mu.Lock()
}
f, ok := fs.files[path]
if !ok {
fs.mu.Unlock()
return nil, fmt.Errorf("file not found: %s", path)
}
content := append([]byte(nil), f.Content...)
fs.mu.Unlock()
return content, nil
}
// ReadFileAt reads a specific range of bytes from a file at the given path.
func (fs *FileSystem) ReadFileAt(path string, offset, size int) ([]byte, error) {
fs.mu.Lock()
if fs.delay > 0 {
fs.mu.Unlock()
time.Sleep(fs.delay)
fs.mu.Lock()
}
f, ok := fs.files[path]
if !ok {
fs.mu.Unlock()
return nil, fmt.Errorf("file not found: %s", path)
}
if offset >= len(f.Content) {
fs.mu.Unlock()
return []byte{}, nil
}
end := offset + size
if end > len(f.Content) {
end = len(f.Content)
}
content := append([]byte(nil), f.Content[offset:end]...)
fs.mu.Unlock()
return content, nil
}
// 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 {
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)
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
if fs.changes != nil {
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.sendNotifications(events)
return nil
}
// DeleteFile removes a file at the given path.
func (fs *FileSystem) DeleteFile(path string) error {
fs.mu.Lock()
if fs.delay > 0 {
fs.mu.Unlock()
time.Sleep(fs.delay)
fs.mu.Lock()
}
if _, ok := fs.files[path]; !ok {
fs.mu.Unlock()
return fmt.Errorf("file not found: %s", path)
}
delete(fs.files, path)
var events []notifyEvent
if fs.changes != nil {
events = append(events, notifyEvent{path, "Deleted"})
}
fs.mu.Unlock()
fs.sendNotifications(events)
return nil
}
// CreateDir creates a directory at the given path.
func (fs *FileSystem) CreateDir(path string) error {
fs.mu.Lock()
if fs.delay > 0 {
fs.mu.Unlock()
time.Sleep(fs.delay)
fs.mu.Lock()
}
name := filepath.Base(path)
f := &File{
Path: path,
Name: name,
ModTime: time.Now(),
IsDir: true,
}
fs.files[path] = f
var events []notifyEvent
if fs.changes != nil {
events = append(events, notifyEvent{path, "Created"})
}
fs.mu.Unlock()
fs.sendNotifications(events)
return nil
}
// ReadDir returns a list of entries in the given directory.
func (fs *FileSystem) ReadDir(path string) ([]types.DirEntry, error) {
fs.mu.Lock()
if fs.delay > 0 {
fs.mu.Unlock()
time.Sleep(fs.delay)
fs.mu.Lock()
}
dirPath := filepath.Clean(path)
if _, ok := fs.files[dirPath]; !ok {
fs.mu.Unlock()
return nil, fmt.Errorf("directory not found: %s", path)
}
var entries []types.DirEntry
for _, f := range fs.files {
if f.Path == dirPath {
continue
}
parent := filepath.Dir(f.Path)
if parent == dirPath {
entries = append(entries, DirEntry{
name: f.Name,
isDir: f.IsDir,
modTime: f.ModTime,
size: f.Size,
})
}
}
// Sort by name
for i := 0; i < len(entries); i++ {
for j := i + 1; j < len(entries); j++ {
if entries[i].Name() > entries[j].Name() {
entries[i], entries[j] = entries[j], entries[i]
}
}
}
fs.mu.Unlock()
return entries, nil
}
// DirExists returns true if the path exists and is a directory.
func (fs *FileSystem) DirExists(path string) bool {
fs.mu.RLock()
defer fs.mu.RUnlock()
f, ok := fs.files[path]
return ok && f.IsDir
}
// FileExists returns true if the path exists and is a file.
func (fs *FileSystem) FileExists(path string) bool {
fs.mu.RLock()
defer fs.mu.RUnlock()
f, ok := fs.files[path]
return ok && !f.IsDir
}
// GetFile returns the file at the given path, or nil if not found.
func (fs *FileSystem) GetFile(path string) *File {
fs.mu.RLock()
defer fs.mu.RUnlock()
return fs.files[path]
}
// SetFileContent atomically replaces the content of an existing file.
func (fs *FileSystem) SetFileContent(path string, content []byte) {
fs.mu.Lock()
if fs.delay > 0 {
fs.mu.Unlock()
time.Sleep(fs.delay)
fs.mu.Lock()
}
f, ok := fs.files[path]
if !ok {
fs.mu.Unlock()
return
}
f.Content = content
f.Size = int64(len(content))
f.ModTime = time.Now()
var events []notifyEvent
if fs.changes != nil {
events = append(events, notifyEvent{path, "Modified"})
}
fs.mu.Unlock()
fs.sendNotifications(events)
}
// AddFile adds a file without triggering change notifications.
// Useful for pre-populating the filesystem before tests.
func (fs *FileSystem) AddFile(path string, content []byte, modTime time.Time) {
fs.mu.Lock()
defer fs.mu.Unlock()
name := filepath.Base(path)
f := &File{
Path: path,
Name: name,
Content: content,
ModTime: modTime,
Size: int64(len(content)),
IsDir: false,
}
fs.files[path] = f
}
// AddDir adds a directory without triggering change notifications.
func (fs *FileSystem) AddDir(path string, modTime time.Time) {
fs.mu.Lock()
defer fs.mu.Unlock()
name := filepath.Base(path)
f := &File{
Path: path,
Name: name,
ModTime: modTime,
IsDir: true,
}
fs.files[path] = f
}
// RemoveFile removes a file without triggering change notifications.
func (fs *FileSystem) RemoveFile(path string) {
fs.mu.Lock()
defer fs.mu.Unlock()
delete(fs.files, path)
}
// ListPaths returns all file paths in the filesystem, optionally filtered by prefix.
func (fs *FileSystem) ListPaths(prefix string) []string {
fs.mu.RLock()
defer fs.mu.RUnlock()
var paths []string
for path := range fs.files {
if prefix == "" || strings.HasPrefix(path, prefix) {
paths = append(paths, path)
}
}
return paths
}
// Count returns the number of files and directories in the mock filesystem.
func (fs *FileSystem) Count() (files int, dirs int) {
fs.mu.RLock()
defer fs.mu.RUnlock()
for _, f := range fs.files {
if f.IsDir {
dirs++
} else {
files++
}
}
return
}