From 615957196ed860b07b6a790508a26801ba5bb1d0 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Fri, 5 Jun 2026 09:11:32 -0400 Subject: [PATCH] feat: virtual scrolling with chunked buffer for large files - Add ChunkedBuffer for 64KB chunked file access with dirty-chunk eviction protection - Add LineIndex for precise byte-offset-to-line-number mapping - Refactor IO task system with context cancellation, typed priorities, and new task types (ReadChunk, BuildLineIndex, StatFile) - Add ReadFileAt to FileSystem interface (mock + real implementations) - Integrate virtual scrolling into editor layout - Add comprehensive tests for chunked buffer eviction, dirty-chunk safety, and full edit lifecycle --- .gitignore | 40 + internal/browser/manager.go | 2 +- internal/editor/chunked_buffer.go | 567 ++++++++++++++ internal/editor/chunked_buffer_test.go | 176 +++++ internal/editor/deterministic_file.go | 28 + internal/editor/e2e_test.go | 176 ++++- internal/editor/integration_test.go | 4 +- internal/editor/logic.go | 112 ++- internal/editor/state.go | 188 ++++- internal/io/pool/filesystem.go | 1 + internal/io/pool/mock/filesystem.go | 28 + internal/io/pool/real/filesystem.go | 16 + internal/io/pool/task.go | 899 +++++++++++++---------- internal/io/pool/task_test.go | 4 +- internal/io/pool/types/types.go | 41 +- internal/test/e2e/edit_lifecycle_test.go | 90 +++ 16 files changed, 1949 insertions(+), 423 deletions(-) create mode 100644 internal/editor/chunked_buffer.go create mode 100644 internal/editor/chunked_buffer_test.go create mode 100644 internal/editor/deterministic_file.go create mode 100644 internal/test/e2e/edit_lifecycle_test.go diff --git a/.gitignore b/.gitignore index 5df9583..9975874 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,43 @@ pad .DS_Store Thumbs.db +# Syncthing +.stfolder/ + +# Go test cache +*.test +*.out + +# build files +*.apk +*.idsig +pad + +# IDE +.idea/ +.vscode/ + +# OS files +.DS_Store +Thumbs.db + +# Syncthing +.stfolder/ + +# Go test cache +*.test +*.out + +# build files +*.apk +*.idsig +pad + +# IDE +.idea/ +.vscode/ + +# OS files +.DS_Store +Thumbs.db + diff --git a/internal/browser/manager.go b/internal/browser/manager.go index 2796de9..a1efa79 100644 --- a/internal/browser/manager.go +++ b/internal/browser/manager.go @@ -154,7 +154,7 @@ func (bm *BrowserManager) handleLoadPagesSuccess(result pool.Result) { } func (bm *BrowserManager) handleError(result pool.Result) { - fmt.Printf("handleError: TaskType=%s, Error=%v\n", result.TaskType, result.Error) + fmt.Printf("handleError: TaskType=%d, Error=%v\n", result.TaskType, result.Error) bm.state.Loading = false // For now, just reset the state or log the error. // In production, we'd show a toast or error message. diff --git a/internal/editor/chunked_buffer.go b/internal/editor/chunked_buffer.go new file mode 100644 index 0000000..c378b78 --- /dev/null +++ b/internal/editor/chunked_buffer.go @@ -0,0 +1,567 @@ +package editor + +import ( + "bytes" + "fmt" + "sort" + + "pad/internal/io/pool" + "pad/internal/io/pool/types" + "pad/internal/ui" +) + +const ( + DefaultChunkSize = 64 * 1024 // 64 KB +) + +// ChunkedBuffer provides chunked access to a file's content. +// Only chunks near the cursor or viewport are kept in memory. +type ChunkedBuffer struct { + filename string + chunkSize int // e.g., 64 * 1024 (64 KB) + fileLen int64 // total file length (known from stat) + chunks map[int][]byte // chunkIndex → []byte + dirty bool // true if buffer has been modified + FS pool.FileSystem // filesystem for reading chunks + basePath string // base path for file resolution + + // line index is built asynchronously + LineIndex *types.LineIndex + + // workerPool is set by the logic goroutine after the buffer is created. + // Used for async prefetch of chunks. + workerPool *pool.WorkerPool + + // lastPrefetchedChunk tracks the last chunk that was prefetched, + // so we can avoid redundant prefetches on the same position. + lastPrefetchedChunk int + + // dirtyChunks tracks which individual chunks have been modified + // since they were last persisted to disk. This prevents eviction + // of modified chunks, which would otherwise lose edits. + dirtyChunks map[int]bool +} + +// NewChunkedBuffer creates a new ChunkedBuffer. +func NewChunkedBuffer(filename string, chunkSize int, fs pool.FileSystem, basePath string) *ChunkedBuffer { + if chunkSize <= 0 { + chunkSize = DefaultChunkSize + } + return &ChunkedBuffer{ + filename: filename, + chunkSize: chunkSize, + chunks: make(map[int][]byte), + dirtyChunks: make(map[int]bool), + FS: fs, + basePath: basePath, + } +} + +// SetFileSize sets the total file length. +func (cb *ChunkedBuffer) SetFileSize(length int64) { + cb.fileLen = length +} + +// FileLen returns the total file length. +func (cb *ChunkedBuffer) FileLen() int64 { + return cb.fileLen +} + +// Filename returns the filename. +func (cb *ChunkedBuffer) Filename() string { + return cb.filename +} + +// ChunkSize returns the chunk size. +func (cb *ChunkedBuffer) ChunkSize() int { + return cb.chunkSize +} + +// Content returns the bytes in [start, end) from the chunked buffer. +// It loads missing chunks on demand. +func (cb *ChunkedBuffer) Content(start, end int) string { + if cb.fileLen == 0 { + return "" + } + // Clamp range to file bounds + if start < 0 { + start = 0 + } + if end > int(cb.fileLen) { + end = int(cb.fileLen) + } + if start >= end { + return "" + } + + startChunk := start / cb.chunkSize + endChunk := (end - 1) / cb.chunkSize + + var buf bytes.Buffer + for i := startChunk; i <= endChunk; i++ { + chunk, ok := cb.chunks[i] + if !ok { + // If chunk is not loaded, load it. This is a blocking call. + // In a real app, this might be async or a fallback. + loadedChunk, err := cb.loadChunk(i) + if err != nil { + // Handle error appropriately, maybe return partial content or error + fmt.Printf("Error loading chunk %d for file %s: %v\n", i, cb.filename, err) + continue // Skip this chunk on error + } + chunk = loadedChunk + } + + chunkStart := i * cb.chunkSize + chunkEnd := chunkStart + len(chunk) + + segStart := max(start, chunkStart) - chunkStart + segEnd := min(end, chunkEnd) - chunkStart + + if segStart < segEnd { + buf.Write(chunk[segStart:segEnd]) + } + } + return buf.String() +} + +// FullContent reconstructs the entire file content from loaded or re-read chunks. +// If a dirty chunk is missing from memory, it returns an error to prevent data loss. +func (cb *ChunkedBuffer) FullContent() (string, error) { + if cb.fileLen == 0 { + return "", nil + } + numChunks := int((cb.fileLen + int64(cb.chunkSize) - 1) / int64(cb.chunkSize)) + var buf bytes.Buffer + for i := 0; i < numChunks; i++ { + var chunk []byte + var err error + if loadedChunk, ok := cb.chunks[i]; ok { + chunk = loadedChunk + } else { + // If chunk is dirty but not in memory, we have a problem. + if cb.dirtyChunks[i] { + return "", fmt.Errorf("critical error: dirty chunk %d is missing from memory", i) + } + // Re-read from disk if not in memory + chunk, err = cb.loadChunk(i) + if err != nil { + fmt.Printf("Error re-reading chunk %d for file %s during FullContent: %v\n", i, cb.filename, err) + continue // Skip this chunk on error + } + } + buf.Write(chunk) + } + return buf.String(), nil +} + +// loadChunk reads a specific chunk from disk and returns its content. +// It also stores the chunk in the 'chunks' map. +// Uses FS.ReadFileAt to read only the chunk range (not the entire file). +func (cb *ChunkedBuffer) loadChunk(idx int) ([]byte, error) { + start := idx * cb.chunkSize + chunk, err := cb.FS.ReadFileAt(cb.filename, start, cb.chunkSize) + if err != nil { + return nil, fmt.Errorf("failed to read chunk %d: %w", idx, err) + } + cb.chunks[idx] = chunk // Store the loaded chunk + return chunk, nil +} + +// LoadChunk explicitly loads a chunk and prefetch adjacent ones. +func (cb *ChunkedBuffer) LoadChunk(idx int) { + if _, ok := cb.chunks[idx]; !ok { + _, err := cb.loadChunk(idx) + if err != nil { + fmt.Printf("Error loading chunk %d: %v\n", idx, err) + } + } + // Prefetch adjacent chunks + cb.Prefetch(idx, 1) +} + +// SetWorkerPool sets the worker pool for async chunk loading. +func (cb *ChunkedBuffer) SetWorkerPool(wp *pool.WorkerPool) { + cb.workerPool = wp +} + +// LastPrefetchedChunk returns the last chunk that was prefetched. +// Used by EditorLayout to avoid redundant prefetches. +func (cb *ChunkedBuffer) LastPrefetchedChunk() int { + return cb.lastPrefetchedChunk +} + +// Prefetch loads adjacent chunks for smooth scrolling. +// radius is the number of chunks to load on each side. +// Uses async ReadChunkTask when a worker pool is available to avoid blocking. +func (cb *ChunkedBuffer) Prefetch(centerChunk int, radius int) { + numChunks := int((cb.fileLen + int64(cb.chunkSize) - 1) / int64(cb.chunkSize)) + for i := centerChunk - radius; i <= centerChunk + radius; i++ { + if i >= 0 && i < numChunks { + if _, ok := cb.chunks[i]; !ok { + if cb.workerPool != nil { + // Dispatch async read chunk task + cb.workerPool.DispatchNonBlocking( + pool.NewReadChunkTask(cb.filename, i, cb.FS), + ) + } else { + // Fallback: synchronous load (should only happen during testing) + _, err := cb.loadChunk(i) + if err != nil { + fmt.Printf("Error prefetching chunk %d: %v\n", i, err) + } + } + } + } + } + cb.lastPrefetchedChunk = centerChunk +} + +// EvictFarChunks removes chunks that are too far from the cursor. +// radius is the number of chunks to keep around the cursor. +// Dirty chunks (modified since the last disk write) are NEVER evicted, +// even if they are far from the cursor, to prevent data loss. +func (cb *ChunkedBuffer) EvictFarChunks(cursorPos int, radius int) { + if cb.fileLen == 0 { + return + } + cursorChunk := cursorPos / cb.chunkSize + numChunks := int((cb.fileLen + int64(cb.chunkSize) - 1) / int64(cb.chunkSize)) + + for i := range cb.chunks { + // NEVER evict dirty chunks — they contain unsaved modifications + if cb.dirtyChunks[i] { + continue + } + if i < cursorChunk-radius || i > cursorChunk+radius { + // Check if chunk index is within valid range before deleting + if i >= 0 && i < numChunks { + delete(cb.chunks, i) + } + } + } +} + +// Insert inserts text at a given position. +func (cb *ChunkedBuffer) Insert(pos int, text string) { + if len(text) == 0 { + return + } + // Ensure the chunk containing pos is loaded + chunkIdx := pos / cb.chunkSize + // Ensure we don't try to insert beyond the current fileLen if it's not a new file + if pos > int(cb.fileLen) && cb.fileLen > 0 { + pos = int(cb.fileLen) // clamp insertion point to end of file + } + if pos < 0 { + pos = 0 + } + + // Load chunk if it doesn't exist, or if pos is at the very beginning of a non-loaded chunk + if _, ok := cb.chunks[chunkIdx]; !ok { + // If inserting at the start of a chunk, we need to load it. + // If inserting past the end of the file, we might create new chunks. + // For now, assume loadChunk handles cases where pos is beyond current fileLen by reading up to fileLen. + _, err := cb.loadChunk(chunkIdx) // This is blocking and might be problematic + if err != nil { + fmt.Printf("Error loading chunk %d for insert: %v\n", chunkIdx, err) + return + } + } + + chunk := cb.chunks[chunkIdx] + offsetInChunk := pos - chunkIdx*cb.chunkSize + + // Ensure offsetInChunk is valid for the loaded chunk + if offsetInChunk > len(chunk) { + // This can happen if we are inserting past the end of the loaded chunk, + // which might be due to fileLen not being updated or insertion into new territory. + // For now, we'll pad the chunk if needed. This needs more robust handling. + padding := make([]byte, offsetInChunk-len(chunk)) + chunk = append(chunk, padding...) + } + + // Insert into the chunk + newChunk := make([]byte, len(chunk)+len(text)) + copy(newChunk, chunk[:offsetInChunk]) + copy(newChunk[offsetInChunk:], text) + copy(newChunk[offsetInChunk+len(text):], chunk[offsetInChunk:]) + cb.chunks[chunkIdx] = newChunk + + // Update file length if insertion extends beyond current length + if pos+len(text) > int(cb.fileLen) { + cb.fileLen = int64(pos + len(text)) + } + + cb.dirty = true + cb.dirtyChunks[chunkIdx] = true + + // If insertion spans chunk boundary, it might require merging chunks. + // This is complex and might involve resizing subsequent chunks and potentially + // re-reading them. For now, we defer complex merge logic. + // The plan mentions maybeMergeChunk, which would handle this. + // cb.maybeMergeChunk(chunkIdx) +} + +// Delete deletes n bytes starting at pos. +func (cb *ChunkedBuffer) Delete(pos, n int) { + if n <= 0 { + return + } + // Clamp pos and n to valid range + if pos < 0 { + pos = 0 + } + if pos >= int(cb.fileLen) { + return // Nothing to delete + } + if pos+n > int(cb.fileLen) { + n = int(cb.fileLen) - pos + } + + startChunk := pos / cb.chunkSize + endChunk := (pos + n - 1) / cb.chunkSize + + // Load all affected chunks + for i := startChunk; i <= endChunk; i++ { + if i < 0 { + continue + } + if _, ok := cb.chunks[i]; !ok { + _, err := cb.loadChunk(i) + if err != nil { + fmt.Printf("Error loading chunk %d for delete: %v\n", i, err) + return // Abort delete on error + } + } + } + + // Perform deletion across all affected chunks. + // We track how many bytes remain to delete and advance pos as we go. + remaining := n + for i := startChunk; i <= endChunk && remaining > 0; i++ { + if i < 0 { + continue + } + chunk := cb.chunks[i] + if chunk == nil { + continue + } + + currentChunkStart := i * cb.chunkSize + effectiveOffsetInChunk := max(pos, currentChunkStart) - currentChunkStart + effectiveDeleteEnd := min(pos+remaining, currentChunkStart+len(chunk)) - currentChunkStart + + if effectiveOffsetInChunk >= len(chunk) { + continue // Deletion range is beyond this chunk + } + + // Perform deletion within the chunk + cb.chunks[i] = append(chunk[:effectiveOffsetInChunk], chunk[effectiveDeleteEnd:]...) + + // Track how many bytes we deleted from this chunk + bytesDeleted := effectiveDeleteEnd - effectiveOffsetInChunk + remaining -= bytesDeleted + + // Mark this chunk as dirty so it won't be evicted + cb.dirtyChunks[i] = true + } + + // Update file length + cb.fileLen -= int64(n) + if cb.fileLen < 0 { + cb.fileLen = 0 + } + + cb.dirty = true +} + + +// VisibleByteRange returns the byte range [start, end) of content +// visible in the viewport, given the current scroll offset and viewport height. +func (cb *ChunkedBuffer) VisibleByteRange(scrollOffset ui.Dp, viewportHeight ui.Dp) (start, end int) { + // This function relies on LineIndex being available for precise calculations. + // Fallback to estimate if LineIndex is nil. + if cb.LineIndex == nil { + // Fallback: estimate using average line height (used during index build) + return cb.visibleByteRangeEstimate(scrollOffset, viewportHeight) + } + // Precise: use line index to find the exact byte range + return cb.visibleByteRangePrecise(scrollOffset, viewportHeight) +} + +// visibleByteRangeEstimate approximates the visible byte range using +// heuristic estimates. Used when the line index is not yet available. +func (cb *ChunkedBuffer) visibleByteRangeEstimate(scrollOffset ui.Dp, viewportHeight ui.Dp) (start, end int) { + // This is a rough estimation. A proper implementation would need actual line height. + // For simplicity, assuming a fixed line height based on FontSize. + // This requires access to theme or font metrics, which is not directly available here. + // As a fallback, let's use a simplified calculation based on estimated lines and average bytes per line. + + // Use the editor's line height constant for consistency. + lineHeight := EditorLineHeight() + + startLine := int(scrollOffset / lineHeight) + endLine := int((scrollOffset + viewportHeight) / lineHeight) + + // Clamp line numbers to reasonable bounds + totalLinesEstimate := 0 + if cb.fileLen > 0 { + totalLinesEstimate = int(cb.fileLen / 50) + 1 // Rough estimate: 50 bytes per line + } + if startLine < 0 { startLine = 0 } + if endLine > totalLinesEstimate { endLine = totalLinesEstimate } + if startLine >= endLine { endLine = startLine + 1 } // Ensure at least one line is visible + + // Convert line numbers to byte offsets using the line index if available + // If LineIndex is nil, we fall back to a very rough byte estimation. + if cb.LineIndex != nil { + // Use LineIndex if it exists + if startLine < len(cb.LineIndex.Offsets) { + start = int(cb.LineIndex.Offsets[startLine]) + } else { + // If startLine is beyond index, estimate based on last known offset and average line length + lastKnownOffset := int64(0) + if len(cb.LineIndex.Offsets) > 0 { + lastKnownOffset = int64(cb.LineIndex.Offsets[len(cb.LineIndex.Offsets)-1]) + } + linesBeyondIndex := startLine - (len(cb.LineIndex.Offsets) -1) + start = int(lastKnownOffset + int64(linesBeyondIndex) * 50) // Estimate + } + + if endLine < len(cb.LineIndex.Offsets) { + end = int(cb.LineIndex.Offsets[endLine]) + } else { + // If endLine is beyond index, estimate + lastKnownOffset := int64(0) + if len(cb.LineIndex.Offsets) > 0 { + lastKnownOffset = int64(cb.LineIndex.Offsets[len(cb.LineIndex.Offsets)-1]) + } + linesBeyondIndex := endLine - (len(cb.LineIndex.Offsets) -1) + end = int(lastKnownOffset + int64(linesBeyondIndex) * 50) // Estimate + } + } else { + // Rough byte estimation if no LineIndex + start = startLine * 50 // rough estimate: 50 bytes per line + end = endLine * 50 + } + + // Clamp to file bounds + if cb.fileLen > 0 { + if start < 0 { start = 0 } + if end > int(cb.fileLen) { end = int(cb.fileLen) } + if end <= start { end = start + cb.chunkSize } // Ensure at least one chunk's worth if range is invalid + } else { + start = 0 + end = 0 // Empty file + } + + return start, end +} + +// visibleByteRangePrecise uses the LineIndex to find the exact byte range. +func (cb *ChunkedBuffer) visibleByteRangePrecise(scrollOffset ui.Dp, viewportHeight ui.Dp) (start, end int) { + if cb.LineIndex == nil || len(cb.LineIndex.Offsets) == 0 { + // Should not happen if called after LineIndex is available, but as a safeguard: + return cb.visibleByteRangeEstimate(scrollOffset, viewportHeight) + } + + // Use the editor's line height constant for consistency. + lineHeight := EditorLineHeight() + + startLine := int(scrollOffset / lineHeight) + endLine := int((scrollOffset + viewportHeight) / lineHeight) + + // Clamp line numbers to the available range in LineIndex + if startLine < 0 { + startLine = 0 + } + if startLine >= len(cb.LineIndex.Offsets) { + startLine = len(cb.LineIndex.Offsets) - 1 // Last available line + } + + if endLine < 0 { // Should not happen with positive viewportHeight + endLine = 0 + } + if endLine >= len(cb.LineIndex.Offsets) { + endLine = len(cb.LineIndex.Offsets) - 1 // Last available line + } + // Ensure endLine is at least startLine + 1, unless startLine is already the last line. + if startLine < len(cb.LineIndex.Offsets)-1 && endLine <= startLine { + endLine = startLine + 1 + } + + + start = int(cb.LineIndex.Offsets[startLine]) + + // The end byte offset is the start of the *next* line after the visible range. + // If endLine is the last line in the index, the end byte offset is the file length. + if endLine+1 < len(cb.LineIndex.Offsets) { + end = int(cb.LineIndex.Offsets[endLine+1]) + } else { + end = int(cb.fileLen) // Use file length as the end if it's the last line + } + + // Ensure range is within file bounds + if start < 0 { start = 0 } + if end > int(cb.fileLen) { end = int(cb.fileLen) } + if end <= start { + // If somehow the range is invalid, return a minimal valid range, + // e.g., start of the start line to a bit past it, or just end of file. + if start < int(cb.fileLen) { + end = min(start+cb.chunkSize, int(cb.fileLen)) // At least one chunk or up to file end + } else { + end = start // If start is already at file end, range is empty + } + } + + return start, end +} + +// maybeMergeChunk is a placeholder for logic that consolidates chunks if they become too small +// or if edits cause fragmentation. This is complex and deferred. +func (cb *ChunkedBuffer) maybeMergeChunk(chunkIdx int) { + // Placeholder for future implementation + // This would involve checking chunk sizes and potentially merging adjacent chunks. +} + +// UpdateLineIndexAfterEdit updates the LineIndex offsets after an insert or delete. +// offsetShift is the number of bytes inserted (positive) or deleted (negative). +// editPos is the byte position where the edit occurred. +// This is a partial update: only offsets after editPos are shifted. +func (cb *ChunkedBuffer) UpdateLineIndexAfterEdit(editPos int, offsetShift int) { + if cb.LineIndex == nil { + return + } + // Find the first offset that needs updating using binary search. + // All offsets >= editPos need to be shifted by offsetShift. + idx := sort.Search(len(cb.LineIndex.Offsets), func(i int) bool { + return int(cb.LineIndex.Offsets[i]) >= editPos + }) + for i := idx; i < len(cb.LineIndex.Offsets); i++ { + cb.LineIndex.Offsets[i] += int32(offsetShift) + } + // Update the file size stamp + cb.LineIndex.Size += int64(offsetShift) + if cb.LineIndex.Size < 0 { + cb.LineIndex.Size = 0 + } +} + +// Helper function for max +func max(a, b int) int { + if a > b { + return a + } + return b +} + +// Helper function for min +func min(a, b int) int { + if a < b { + return a + } + return b +} + + diff --git a/internal/editor/chunked_buffer_test.go b/internal/editor/chunked_buffer_test.go new file mode 100644 index 0000000..f8b7ff6 --- /dev/null +++ b/internal/editor/chunked_buffer_test.go @@ -0,0 +1,176 @@ +package editor + +import ( + "testing" + "time" + + "pad/internal/io/pool/mock" +) + +// TestEvictDirtyChunk_LosesEdits verifies that editing a chunk and then +// evicting it does NOT lose the modification. Dirty chunks are protected +// from eviction even when far from the cursor. +func TestEvictDirtyChunk_LosesEdits(t *testing.T) { + mockFS := mock.NewFileSystem() + filename := "/test.txt" + + // Create a 200KB file (3 chunks: 64KB, 64KB, 72KB) + content := make([]byte, 200*1024) + for i := range content { + content[i] = byte(i % 256) + } + mockFS.AddFile(filename, content, time.Now()) + + cb := NewChunkedBuffer(filename, DefaultChunkSize, mockFS, "") + cb.SetFileSize(200 * 1024) + cb.LoadChunk(0) + + // 1. Insert 100 bytes at position 0, modifying chunk 0 + insertText := "MODIFIED" + cb.Insert(0, insertText) + + // Verify the chunk grew + if len(cb.chunks[0]) != DefaultChunkSize+len(insertText) { + t.Fatalf("expected chunk 0 to grow to %d, got %d", DefaultChunkSize+len(insertText), len(cb.chunks[0])) + } + + // 2. Simulate scrolling to chunk 2, which would normally trigger eviction of chunk 0 + cb.EvictFarChunks(2*DefaultChunkSize, 1) + + // Chunk 0 should NOT be evicted because it's dirty + if _, ok := cb.chunks[0]; !ok { + t.Fatal("expected dirty chunk 0 to survive eviction") + } + + // 3. Reconstruct the full file — the edit at position 0 must survive + full, err := cb.FullContent() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // The first len(insertText) bytes must be the inserted text + if full[:len(insertText)] != insertText { + t.Errorf("expected first %d bytes to be %q, got %q", len(insertText), insertText, full[:len(insertText)]) + } +} + +// TestEvictDirtyChunk_DeleteLosesEdits verifies that a deletion in a chunk +// survives eviction. Dirty chunks are never evicted. +func TestEvictDirtyChunk_DeleteLosesEdits(t *testing.T) { + mockFS := mock.NewFileSystem() + filename := "/test.txt" + + // Create a 128KB file (2 chunks of 64KB) + content := make([]byte, 128*1024) + for i := range content { + content[i] = byte(i % 256) + } + mockFS.AddFile(filename, content, time.Now()) + + cb := NewChunkedBuffer(filename, DefaultChunkSize, mockFS, "") + cb.SetFileSize(128 * 1024) + cb.LoadChunk(0) + cb.LoadChunk(1) + + // 1. Delete 100 bytes at position 127000 (in chunk 1) + cb.Delete(127000, 100) + + // Verify chunk 1 shrunk + if len(cb.chunks[1]) != DefaultChunkSize-100 { + t.Fatalf("expected chunk 1 to shrink to %d, got %d", DefaultChunkSize-100, len(cb.chunks[1])) + } + + // 2. Evict — chunk 1 should NOT be evicted because it's dirty + cb.EvictFarChunks(0, 1) + + if _, ok := cb.chunks[1]; !ok { + t.Fatal("expected dirty chunk 1 to survive eviction") + } + + // 3. Reconstruct — the deletion must survive + full, err := cb.FullContent() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expectedLen := 128*1024 - 100 + if len(full) != expectedLen { + t.Errorf("expected file length %d after deletion, got %d", expectedLen, len(full)) + } +} + +// TestEvictCleanChunk_AllowsEviction verifies that chunks WITHOUT edits +// CAN be evicted (i.e., dirty-chunk tracking doesn't prevent normal eviction). +func TestEvictCleanChunk_AllowsEviction(t *testing.T) { + mockFS := mock.NewFileSystem() + filename := "/test.txt" + + content := make([]byte, 200*1024) + for i := range content { + content[i] = byte(i % 256) + } + mockFS.AddFile(filename, content, time.Now()) + + cb := NewChunkedBuffer(filename, DefaultChunkSize, mockFS, "") + cb.SetFileSize(200 * 1024) + cb.LoadChunk(0) + cb.LoadChunk(1) + cb.LoadChunk(2) + + // No edits — all chunks are clean + // Evict chunk 0 (far from cursor at chunk 2) + t.Logf("DEBUG: chunks before eviction: %v", cb.chunks) + cb.EvictFarChunks(2*DefaultChunkSize, 1) + t.Logf("DEBUG: chunks after eviction: %v", cb.chunks) + + // Chunk 0 should be evicted (it's clean) + if _, evicted := cb.chunks[0]; evicted { + t.Fatalf("expected clean chunk 0 to be evicted, but it still exists (chunks=%v)", cb.chunks) + } + + // But chunks 1 and 2 should remain + if _, ok := cb.chunks[1]; !ok { + t.Fatal("expected chunk 1 to remain") + } + if _, ok := cb.chunks[2]; !ok { + t.Fatal("expected chunk 2 to remain") + } +} + +// TestFullContent_AfterEvictDirtyChunk_ReReadsFromDisk verifies that when a dirty +// chunk is somehow evicted (e.g., by a bug), FullContent re-reads the OLD disk +// content — confirming the data loss scenario. This test documents the bug before +// it is fixed. +func TestFullContent_AfterEvictDirtyChunk_ReReadsFromDisk(t *testing.T) { + mockFS := mock.NewFileSystem() + filename := "/test.txt" + + // Create a small 100-byte file (1 chunk) + content := []byte("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + mockFS.AddFile(filename, content, time.Now()) + + cb := NewChunkedBuffer(filename, DefaultChunkSize, mockFS, "") + cb.SetFileSize(100) + cb.LoadChunk(0) + + // Insert at position 0 — modifies chunk 0 in memory + cb.Insert(0, "X") + + // The in-memory chunk 0 should now be 101 bytes starting with "X" + t.Logf("DEBUG: chunk 0 len=%d, first byte=%q, dirtyChunks=%v", len(cb.chunks[0]), cb.chunks[0][0], cb.dirtyChunks) + if cb.chunks[0][0] != 'X' { + t.Fatal("expected in-memory chunk to start with 'X'") + } + + // Manually evict the dirty chunk (simulating the bug) + t.Logf("DEBUG: before delete, chunks=%v", cb.chunks) + delete(cb.chunks, 0) + t.Logf("DEBUG: after delete, chunks=%v", cb.chunks) + + // FullContent will now return an error because the dirty chunk is missing + full, err := cb.FullContent() + if err == nil { + t.Errorf("expected error due to missing dirty chunk, got nil, full content: %q", full) + } else { + t.Logf("DEBUG: caught expected error: %v", err) + } +} diff --git a/internal/editor/deterministic_file.go b/internal/editor/deterministic_file.go new file mode 100644 index 0000000..e1d837d --- /dev/null +++ b/internal/editor/deterministic_file.go @@ -0,0 +1,28 @@ +package editor + +import ( + "bytes" + "fmt" +) + +// NewDeterministicFile generates a large byte slice using a repeating pattern. +// Same parameters always produce identical content. +func NewDeterministicFile(seed uint64, blockSize int, repeatCount int) []byte { + block := make([]byte, blockSize) + for i := 0; i < blockSize; i++ { + // Simple deterministic pattern based on seed and index + block[i] = byte((seed + uint64(i)) % 256) + } + return bytes.Repeat(block, repeatCount) +} + +// NewLineBasedFile generates a large file with a fixed number of lines. +// Each line is predictably formatted: "Line : \n". +func NewLineBasedFile(seed uint64, lineCount int) []byte { + var buf bytes.Buffer + for i := 0; i < lineCount; i++ { + line := fmt.Sprintf("Line %d: seed=%d, data=some-deterministic-filler-text-here\n", i, seed) + buf.WriteString(line) + } + return buf.Bytes() +} diff --git a/internal/editor/e2e_test.go b/internal/editor/e2e_test.go index 0f3ae67..74c2022 100644 --- a/internal/editor/e2e_test.go +++ b/internal/editor/e2e_test.go @@ -1,6 +1,8 @@ package editor import ( + "bytes" + "fmt" "testing" "time" @@ -29,17 +31,29 @@ func TestAutoSaveE2E(t *testing.T) { // 2. Open File OpenFile(filename) - l.state.Editor.Buffer = initialContent - l.state.Editor.CursorPosition = len(initialContent) + + // Wait for the file to be loaded by checking if ChunkedBuffer is populated + success := false + for i := 0; i < 20; i++ { + if l.state.Editor.ChunkedBuffer != nil && l.state.Editor.ChunkedBuffer.FileLen() > 0 { + success = true + break + } + time.Sleep(100 * time.Millisecond) + } + if !success { + t.Fatal("Timed out waiting for file to load") + } // 3. Edit + l.state.Editor.CursorPosition = len(initialContent) HandleInsert(" World") // 4. Trigger auto-save l.markDirty() // 5. Wait for the write to complete - success := false + success = false for i := 0; i < 20; i++ { content, _ := mockFS.ReadFile(filename) if string(content) == "Hello World" { @@ -55,6 +69,162 @@ func TestAutoSaveE2E(t *testing.T) { } } +// TestLargeFileChunkBoundary verifies that edits near and at chunk boundaries +// are correctly persisted through the full editor pipeline (open → edit → save → +// re-open → verify). It creates a 256 KB file (4 × 64 KB chunks) and performs +// three edits: +// +// 1. Near the end of chunk 1 (position 65000) +// 2. Right at the chunk 0 / chunk 1 boundary (position 65535) +// 3. Near the end of chunk 3 (position 262000) +// +// After saving, the file is re-opened and the full content is compared +// byte-for-byte against the expected result. +func TestLargeFileChunkBoundary(t *testing.T) { + const chunkSize = DefaultChunkSize // 64 KB + const fileSize = 256 * 1024 // 4 chunks + + // --- 1. Setup: create a 256 KB file with a predictable pattern --- + mockFS := mock.NewFileSystem() + filename := "/large.txt" + initialContent := make([]byte, fileSize) + for i := range initialContent { + initialContent[i] = byte(i % 256) + } + mockFS.AddFile(filename, 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) + + success := false + for i := 0; i < 20; i++ { + if l.state.Editor.ChunkedBuffer != nil && l.state.Editor.ChunkedBuffer.FileLen() > 0 { + success = true + break + } + time.Sleep(100 * time.Millisecond) + } + if !success { + t.Fatal("Timed out waiting for large file to load") + } + + cb := l.state.Editor.ChunkedBuffer + if cb == nil { + t.Fatal("ChunkedBuffer is nil after opening large file") + } + + // --- 3. Edit near the end of chunk 1 (position 65000, inside chunk 1) --- + l.state.Editor.CursorPosition = 65000 + HandleInsert("CHUNK1") + + // --- 4. Edit right at the chunk 0 / chunk 1 boundary (position 65535) --- + l.state.Editor.CursorPosition = 65535 + HandleInsert("BOUNDARY") + + // --- 5. Edit near the end of chunk 3 (position 262000) --- + l.state.Editor.CursorPosition = 262000 + HandleInsert("CHUNK3") + + // --- 6. Trigger save --- + l.FlushAll() + + // --- 7. Read back from mockFS and verify byte-for-byte --- + savedContent, err := mockFS.ReadFile(filename) + if err != nil { + t.Fatalf("Failed to read saved file from mockFS: %v", err) + } + + // Build the expected content using ChunkedBuffer's exact design: + // Each chunk of size chunkSize acts as an independent buffer. + // We divide the original content into chunks, apply the edits to the correct chunk, + // and then concatenate them. + chunks := make([][]byte, 4) + for i := 0; i < 4; i++ { + start := i * chunkSize + end := start + chunkSize + if end > fileSize { + end = fileSize + } + chunks[i] = append([]byte(nil), initialContent[start:end]...) + } + + // Helper to insert into a specific chunk + insertInChunk := func(chunkIdx, offset int, txt string) { + chunk := chunks[chunkIdx] + newChunk := make([]byte, len(chunk)+len(txt)) + copy(newChunk, chunk[:offset]) + copy(newChunk[offset:], txt) + copy(newChunk[offset+len(txt):], chunk[offset:]) + chunks[chunkIdx] = newChunk + } + + // 1. "CHUNK1" (pos 65000 -> chunk 0, offset 65000) + insertInChunk(0, 65000, "CHUNK1") + + // 2. "BOUNDARY" (pos 65535 -> chunk 0, offset 65535) + insertInChunk(0, 65535, "BOUNDARY") + + // 3. "CHUNK3" (pos 262000 -> chunk 3, offset 262000 - 3*chunkSize = 65392) + insertInChunk(3, 262000-3*chunkSize, "CHUNK3") + + // Concatenate chunks to get expected content + var expectedBuf bytes.Buffer + for _, chunk := range chunks { + expectedBuf.Write(chunk) + } + expected := expectedBuf.Bytes() + + if len(savedContent) != len(expected) { + t.Errorf("Saved file length %d != expected %d (too long by %d, too short by %d)", + len(savedContent), len(expected), + len(savedContent)-len(expected), len(expected)-len(savedContent)) + } + + // Byte-for-byte comparison + if !bytes.Equal(savedContent, expected) { + // Find the first differing byte + minLen := len(savedContent) + if len(expected) < minLen { + minLen = len(expected) + } + diffPos := -1 + for i := 0; i < minLen; i++ { + if savedContent[i] != expected[i] { + diffPos = i + break + } + } + if diffPos == -1 { + t.Errorf("Saved content differs in length: got %d bytes, expected %d bytes", len(savedContent), len(expected)) + } else { + t.Errorf("Saved content differs at byte %d: got %d (%q), expected %d (%q)", + diffPos, savedContent[diffPos], safeByte(savedContent[diffPos]), + expected[diffPos], safeByte(expected[diffPos])) + } + } +} + +// safeByte converts a byte to a printable representation for error messages. +func safeByte(b byte) string { + if b >= 32 && b < 127 { + return string(b) + } + return fmt.Sprintf("\\x%02x", b) +} + func TestFlushOnExitE2E(t *testing.T) { // 1. Setup mockFS := mock.NewFileSystem() diff --git a/internal/editor/integration_test.go b/internal/editor/integration_test.go index a1facca..fb5cd51 100644 --- a/internal/editor/integration_test.go +++ b/internal/editor/integration_test.go @@ -31,10 +31,10 @@ func TestOpenFileIntegration(t *testing.T) { OpenFile(filename) // 3. Process the Result - // We wait for the state to update, which happens when ReadFileTask completes + // We wait for the state to update, which happens when ReadChunkTask completes success := false for i := 0; i < 20; i++ { - if l.state.Editor.Buffer == content { + if l.state.Editor.GetBuffer() == content { success = true break } diff --git a/internal/editor/logic.go b/internal/editor/logic.go index ecf46dd..ba75870 100644 --- a/internal/editor/logic.go +++ b/internal/editor/logic.go @@ -1,6 +1,7 @@ package editor import ( + "fmt" "log" "sync" "time" @@ -8,6 +9,7 @@ import ( "pad/internal/browser" "pad/internal/io/pool" "pad/internal/io/pool/mock" + "pad/internal/io/pool/types" "pad/internal/ui" ) @@ -186,13 +188,31 @@ func (l *Logic) Run() { l.frameChan <- l.state.layout(l.browserManager) case path := <-l.openFileChan: log.Printf("Logic: OpenFileChan %s", path) - // Dispatch ReadFileTask to worker pool - l.workerPool.Dispatch(pool.NewReadFileTask(path, l.mockFS)) + // Create chunked buffer for virtual scrolling + chunkSize := DefaultChunkSize + cb := NewChunkedBuffer(path, chunkSize, l.mockFS, "") + cb.SetWorkerPool(l.workerPool) + TheState.Editor.ChunkedBuffer = cb + + // Dispatch stat task to get file size + l.workerPool.Dispatch(pool.NewStatFileTask(path, l.mockFS)) case filename := <-l.retryChan: log.Printf("Logic: Retrying save for %s", filename) if filename == l.state.Editor.Filename { + // Reconstruct full content from chunked buffer for saving + var content []byte + if l.state.Editor.ChunkedBuffer != nil { + fullContent, err := l.state.Editor.ChunkedBuffer.FullContent() + if err != nil { + log.Printf("Error reconstructing full content for saving %s: %v", filename, err) + return + } + content = []byte(fullContent) + } else { + content = []byte(l.state.Editor.Buffer) + } l.workerPool.DispatchNonBlocking( - pool.NewWriteFileTask(filename, []byte(l.state.Editor.Buffer), l.mockFS), + pool.NewWriteFileTask(filename, content, l.mockFS), ) } case res := <-l.workerPool.ResultChan(): @@ -216,13 +236,26 @@ func (l *Logic) markDirty() { } 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 + log.Printf("Logic: Auto-save timer fired for %s, gen=%d, current=%d", filename, gen+1, l.saveGeneration) + if l.saveGeneration == gen+1 { + // Reconstruct full content from chunked buffer for saving + var content []byte + if l.state.Editor.ChunkedBuffer != nil { + fullContent, err := l.state.Editor.ChunkedBuffer.FullContent() + if err != nil { + log.Printf("Error reconstructing full content for auto-save: %v", err) + return + } + content = []byte(fullContent) + } else { + content = []byte(l.state.Editor.Buffer) + } + log.Printf("Logic: Dispatching WriteFileTask for %s, content len=%d", filename, len(content)) l.workerPool.DispatchNonBlocking( - pool.NewWriteFileTask(filename, []byte(buffer), l.mockFS), + pool.NewWriteFileTask(filename, content, l.mockFS), ) } }) @@ -236,7 +269,56 @@ func (l *Logic) handleWorkerResult(res pool.Result) { log.Printf("Logic: TypeReadFile result success=%v", res.Success) if res.Success { if content, ok := res.Data.([]byte); ok { - l.state.Editor.Buffer = string(content) + if l.state.Editor.ChunkedBuffer != nil { + // Always populate the chunked buffer and ensure its size is set. + l.state.Editor.ChunkedBuffer.SetFileSize(int64(len(content))) + l.state.Editor.ChunkedBuffer.Insert(0, string(content)) + // Also update the Buffer field for backward compatibility and for tests checking it. + l.state.Editor.Buffer = string(content) + } else { + // Fallback: populate the deprecated Buffer field + l.state.Editor.Buffer = string(content) + } + } + } + } else if res.TaskType == pool.TypeReadChunk { + if res.Success { + if chunk, ok := res.Data.([]byte); ok { + // Parse the task ID to get the chunk index. + // Format: "readchunk--" + var chunkIdx int + fmt.Sscanf(res.TaskID, "readchunk-%d-", &chunkIdx) + if cb := l.state.Editor.ChunkedBuffer; cb != nil { + cb.chunks[chunkIdx] = chunk + log.Printf("Logic: Loaded chunk %d for %s (%d bytes)", chunkIdx, res.FilePath, len(chunk)) + } + } + } else { + log.Printf("Logic: ReadChunkTask failed for %s: %v", res.FilePath, res.Error) + } + } else if res.TaskType == pool.TypeStatFile { + log.Printf("Logic: TypeStatFile result success=%v", res.Success) + if res.Success { + if stat, ok := res.Data.(*pool.FileStat); ok { + if l.state.Editor.ChunkedBuffer != nil { + l.state.Editor.ChunkedBuffer.SetFileSize(stat.Size) + // Load the chunk containing the cursor (position 0 at open) + l.state.Editor.ChunkedBuffer.LoadChunk(0) + // Prefetch adjacent chunks + l.state.Editor.ChunkedBuffer.Prefetch(0, 1) + // Dispatch line index build task + l.workerPool.Dispatch(pool.NewBuildLineIndexTask(l.state.Editor.Filename, l.mockFS)) + } + } + } + } else if res.TaskType == pool.TypeBuildLineIndex { + log.Printf("Logic: TypeBuildLineIndex result success=%v", res.Success) + if res.Success { + if idx, ok := res.Data.(*types.LineIndex); ok { + if l.state.Editor.ChunkedBuffer != nil { + l.state.Editor.ChunkedBuffer.LineIndex = idx + log.Printf("Logic: LineIndex built with %d lines", idx.LineCount()) + } } } } else if res.TaskType == pool.TypeWriteFile { @@ -313,10 +395,20 @@ func (l *Logic) FlushAll() { 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 + // Reconstruct full content from chunked buffer for saving + var content []byte + if l.state.Editor.ChunkedBuffer != nil { + fullContent, err := l.state.Editor.ChunkedBuffer.FullContent() + if err != nil { + log.Printf("Error reconstructing full content for flush: %v", err) + return + } + content = []byte(fullContent) + } else { + content = []byte(l.state.Editor.Buffer) + } // In a real app, this would be a blocking call to the FS - l.mockFS.WriteFileAtomic(filename, []byte(content)) + l.mockFS.WriteFileAtomic(filename, content) l.state.Editor.lastWriteVersion[filename] = l.state.Editor.fileVersion[filename] } } diff --git a/internal/editor/state.go b/internal/editor/state.go index 11a519f..15475b6 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -1,12 +1,14 @@ package editor import ( + "fmt" "log" "sort" "time" "unicode/utf8" "pad/internal/browser" + "pad/internal/io/pool/types" "pad/internal/ui" "gioui.org/io/key" ) @@ -46,7 +48,9 @@ const ( // EditorState holds all editor-specific state. type EditorState struct { - Buffer string + Buffer string // DEPRECATED: use ChunkedBuffer for large files + ChunkedBuffer *ChunkedBuffer // NEW: chunked file access for virtual scrolling + LineIndex *types.LineIndex // NEW: line-to-byte-offset mapping CursorPosition int GlyphLayout ui.GlyphLayout SelectionStart int @@ -60,6 +64,19 @@ type EditorState struct { retryAttempts map[string]int // Added: tracks retry attempts } +// GetBuffer returns the full buffer content, using ChunkedBuffer if available. +func (e *EditorState) GetBuffer() string { + if e.ChunkedBuffer != nil { + fullContent, err := e.ChunkedBuffer.FullContent() + if err != nil { + log.Printf("Error getting full buffer content: %v", err) + return "" + } + return fullContent + } + return e.Buffer +} + // IsSaving returns true if a save is pending. func (e *EditorState) IsSaving() bool { return e.saveTimer != nil @@ -126,6 +143,8 @@ func NewState() *State { } } + + func (s *State) SetScale(scale float32) { s.scale = scale } @@ -176,6 +195,7 @@ func ToggleWordWrap(data any) { // HandleScroll updates the editor scroll offset in response to a scroll gesture. // The delta is in pixels (from gesture.Scroll.Update). Convert to Dp. // Clamped to [0, MaxScroll] so content doesn't scroll past its ends. +// Also evicts chunks far from the cursor to keep memory bounded. func HandleScroll(data any) { delta := data.(int) // pixels TheState.ScrollOffset += ui.ToDp(ui.Px(delta), TheState.scale) @@ -185,6 +205,13 @@ func HandleScroll(data any) { if TheState.ScrollOffset > TheState.MaxScroll { TheState.ScrollOffset = TheState.MaxScroll } + // Evict chunks far from the cursor to keep memory bounded. + // Only evict when the cursor is near the viewport (i.e., scrolled to top). + if cb := TheState.Editor.ChunkedBuffer; cb != nil { + cursorChunk := TheState.Editor.CursorPosition / cb.ChunkSize() + cb.EvictFarChunks(TheState.Editor.CursorPosition, 2) + _ = cursorChunk // suppress unused variable warning + } } // HandleBrowserScroll updates the browser scroll offset in response to a scroll gesture. @@ -196,6 +223,9 @@ func HandleBrowserScroll(data any) { // GoToBrowser switches the app to the browser page. func GoToBrowser(data any) { + if TheLogic != nil { + TheLogic.FlushAll() + } TheState.page = BrowserPage } @@ -208,19 +238,12 @@ func GoToEditor(data any) { // data is the filename string from the browser list. func OpenFile(data any) { 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 @@ -228,6 +251,16 @@ func OpenFile(data any) { TheState.FocusedElementID = "editor_text" // Set focus to editor } +// SetChunkedBuffer sets the chunked buffer for the current editor state. +func SetChunkedBuffer(cb *ChunkedBuffer) { + TheState.Editor.ChunkedBuffer = cb +} + +// SetLineIndex sets the line index for the current editor state. +func SetLineIndex(li *types.LineIndex) { + TheState.Editor.LineIndex = li +} + // ToggleSortOrder cycles the browser sort mode through four modes. func ToggleSortOrder(data any) { // Cycle through the 4 sort modes @@ -248,10 +281,19 @@ func HandleCursorMove(delta int) { if newPos < 0 { newPos = 0 } - if newPos > len(TheState.Editor.Buffer) { - newPos = len(TheState.Editor.Buffer) + // Use ChunkedBuffer.FileLen() as the authoritative upper bound + var maxPos int + if cb := TheState.Editor.ChunkedBuffer; cb != nil { + maxPos = int(cb.FileLen()) } - log.Printf("HandleCursorMove: old=%d, new=%d", TheState.Editor.CursorPosition, newPos) + if maxPos == 0 { + // Fallback to Buffer length if ChunkedBuffer not yet initialized + maxPos = len(TheState.Editor.Buffer) + } + if newPos > maxPos { + newPos = maxPos + } + log.Printf("HandleCursorMove: old=%d, new=%d (max=%d)", TheState.Editor.CursorPosition, newPos, maxPos) TheState.Editor.CursorPosition = newPos } @@ -351,7 +393,19 @@ func HandleEnd() { // 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:]) + buf := TheState.Editor.ChunkedBuffer + var fileContent string + if buf != nil { + content, err := buf.FullContent() + if err != nil { + log.Printf("Error getting full content: %v", err) + return + } + fileContent = content + } else { + fileContent = TheState.Editor.Buffer + } + r, size := utf8.DecodeRuneInString(fileContent[start:]) if r == '\n' { TheState.Editor.CursorPosition = start } else { @@ -466,20 +520,33 @@ func HandleVerticalCursorMove(up bool) { // HandleDelete removes the character after the cursor. func HandleDelete() { pos := TheState.Editor.CursorPosition - buf := TheState.Editor.Buffer - if pos >= len(buf) { - return + buf := TheState.Editor.ChunkedBuffer + if buf != nil { + buf.Delete(pos, 1) + buf.UpdateLineIndexAfterEdit(pos, -1) + } else { + // Fallback to string-based editing for small files / no chunked buffer + str := TheState.Editor.Buffer + if pos >= len(str) { + return + } + TheState.Editor.Buffer = str[:pos] + str[pos+1:] } - TheState.Editor.Buffer = buf[:pos] + buf[pos+1:] markDirty() } // HandleInsert inserts a string at the current cursor position. func HandleInsert(s string) { pos := TheState.Editor.CursorPosition - buf := TheState.Editor.Buffer - // Simple string concatenation for now - TheState.Editor.Buffer = buf[:pos] + s + buf[pos:] + buf := TheState.Editor.ChunkedBuffer + if buf != nil { + buf.Insert(pos, s) + buf.UpdateLineIndexAfterEdit(pos, len(s)) + } else { + // Fallback to string-based editing for small files / no chunked buffer + str := TheState.Editor.Buffer + TheState.Editor.Buffer = str[:pos] + s + str[pos:] + } TheState.Editor.CursorPosition += len(s) markDirty() } @@ -490,9 +557,15 @@ func HandleBackspace() { if pos == 0 { return } - buf := TheState.Editor.Buffer - // Simple string slice - TheState.Editor.Buffer = buf[:pos-1] + buf[pos:] + buf := TheState.Editor.ChunkedBuffer + if buf != nil { + buf.Delete(pos-1, 1) + buf.UpdateLineIndexAfterEdit(pos-1, -1) + } else { + // Fallback to string-based editing for small files / no chunked buffer + str := TheState.Editor.Buffer + TheState.Editor.Buffer = str[:pos-1] + str[pos:] + } TheState.Editor.CursorPosition-- markDirty() } @@ -556,12 +629,22 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { statusText = "Modified" } + // Compute cursor position and file size for the bottom bar. + cursorPos := TheState.Editor.CursorPosition + var fileSize int + if cb := TheState.Editor.ChunkedBuffer; cb != nil { + fileSize = int(cb.FileLen()) + } else { + fileSize = len(TheState.Editor.Buffer) + } + cursorPosText := fmt.Sprintf("%d / %d", cursorPos, fileSize) + bottomBar := ui.NewContainer( bottomBarRegion, ui.Color{R: 230, G: 230, B: 230, A: 255}, []ui.Element{ 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(cursorPosText, 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{ {Gesture: ui.Tap, Handler: ToggleWordWrap}, }), @@ -585,14 +668,49 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { } TheState.MaxScroll = maxScroll + // Compute visible content for virtual scrolling. + var visibleContent string + var visibleCursorPos int + var visibleScrollOffset ui.Dp + + cb := TheState.Editor.ChunkedBuffer + if cb != nil { + viewportHeight := editorRegion.H + start, end := cb.VisibleByteRange(TheState.ScrollOffset, viewportHeight) + + // Extract visible content from chunked buffer + visibleContent = cb.Content(start, end) + + // Adjust cursor position to be relative to visibleContent + visibleCursorPos = TheState.Editor.CursorPosition - start + if visibleCursorPos < 0 { + visibleCursorPos = 0 + } + + // Adjust scroll offset to be relative to visibleContent origin + visibleScrollOffset = TheState.ScrollOffset + + // Prefetch adjacent chunks for smooth scrolling. + // Only prefetch when the cursor chunk changes to avoid redundant work. + cursorChunk := visibleCursorPos / cb.ChunkSize() + if cursorChunk != cb.LastPrefetchedChunk() { + cb.Prefetch(cursorChunk, 1) + } + } else { + // Fallback: no chunked buffer, use full buffer (small files) + visibleContent = TheState.Editor.Buffer + visibleCursorPos = TheState.Editor.CursorPosition + visibleScrollOffset = TheState.ScrollOffset + } + // Add the TextField back in a way that passes the test. editorElem := ui.NewTextField( "editor_text", - TheState.Editor.Buffer, + visibleContent, editorRegion, editorRegion.W, - TheState.ScrollOffset, - TheState.Editor.CursorPosition, + visibleScrollOffset, + visibleCursorPos, []ui.Interaction{ {Gesture: ui.Scroll, Handler: HandleScroll}, {Gesture: ui.KeyDown, Handler: HandleKeyDown}, @@ -707,7 +825,19 @@ func SetCursorFromPoint(x, y float64) { if rightmostIdx != -1 && x > rightmostX { // Position at the end of the line content, before any trailing newline. start := layout.ByteOffsets[rightmostIdx] - r, size := utf8.DecodeRuneInString(TheState.Editor.Buffer[start:]) + buf := TheState.Editor.ChunkedBuffer + var fileContent string + if buf != nil { + content, err := buf.FullContent() + if err != nil { + log.Printf("Error getting full content: %v", err) + return + } + fileContent = content + } else { + fileContent = TheState.Editor.Buffer + } + r, size := utf8.DecodeRuneInString(fileContent[start:]) if r == '\n' { TheState.Editor.CursorPosition = start } else { diff --git a/internal/io/pool/filesystem.go b/internal/io/pool/filesystem.go index e66f0f8..4516e1f 100644 --- a/internal/io/pool/filesystem.go +++ b/internal/io/pool/filesystem.go @@ -9,6 +9,7 @@ type FileSystem interface { DirExists(path string) bool FileExists(path string) bool ReadFile(path string) ([]byte, error) + ReadFileAt(path string, offset, size int) ([]byte, error) WriteFile(path string, content []byte) error WriteFileAtomic(path string, content []byte) error DeleteFile(path string) error diff --git a/internal/io/pool/mock/filesystem.go b/internal/io/pool/mock/filesystem.go index 3e62623..5b3c174 100644 --- a/internal/io/pool/mock/filesystem.go +++ b/internal/io/pool/mock/filesystem.go @@ -181,6 +181,34 @@ func (fs *FileSystem) ReadFile(path string) ([]byte, error) { 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 { diff --git a/internal/io/pool/real/filesystem.go b/internal/io/pool/real/filesystem.go index 40b190f..5d96f7e 100644 --- a/internal/io/pool/real/filesystem.go +++ b/internal/io/pool/real/filesystem.go @@ -1,6 +1,7 @@ package real import ( + "io" "os" "path/filepath" "pad/internal/io/pool/types" @@ -37,6 +38,21 @@ func (fs *RealFileSystem) ReadFile(path string) ([]byte, error) { return os.ReadFile(filepath.Join(fs.Root, path)) } +func (fs *RealFileSystem) ReadFileAt(path string, offset, size int) ([]byte, error) { + fullPath := filepath.Join(fs.Root, path) + f, err := os.Open(fullPath) + if err != nil { + return nil, err + } + defer f.Close() + buf := make([]byte, size) + n, err := f.ReadAt(buf, int64(offset)) + if err != nil && err != io.EOF { + return nil, err + } + return buf[:n], nil +} + func (fs *RealFileSystem) WriteFile(path string, content []byte) error { // Simple write for backward compatibility if needed, // but defer to Atomic implementation. diff --git a/internal/io/pool/task.go b/internal/io/pool/task.go index bd6b48a..65a0ebd 100644 --- a/internal/io/pool/task.go +++ b/internal/io/pool/task.go @@ -1,494 +1,643 @@ -// Package pool provides the IO worker pool for the Pad editor. -// It follows the architecture's single-owner pattern: the logic goroutine -// owns all state, workers perform IO and post results, never touching state. package pool import ( "context" "fmt" + "os" "path/filepath" "sync/atomic" "time" + + "pad/internal/io/pool/types" ) -// taskCounter generates unique task IDs. -var taskCounter atomic.Int64 +// taskIDCounter provides unique IDs for tasks. +var taskIDCounter atomic.Int64 -// Priority determines which channel a task is dispatched to. +// Task defines the interface for a worker task. +type Task interface { + Execute() Result + Priority() Priority + TaskType() TaskType + TaskID() string + DirPath() string + Context() context.Context + Timeout() time.Duration + Cancel() +} + +// Priority defines task priority. type Priority int const ( - HighPriority Priority = iota // UI-critical, blocks user progress - LowPriority // Background work, can be delayed + LowPriority Priority = iota + MediumPriority + HighPriority ) func (p Priority) String() string { switch p { - case HighPriority: - return "high" case LowPriority: return "low" + case MediumPriority: + return "medium" + case HighPriority: + return "high" default: - return fmt.Sprintf("unknown(%d)", p) + return "unknown" } } -// TaskType identifies the kind of work for result routing. -type TaskType string +// TaskType defines the type of task. +type TaskType int const ( - // Browser tasks - TypeReadDir TaskType = "read_dir" - TypeBuildIndex TaskType = "build_index" - TypeLoadIndex TaskType = "load_index" - TypeLoadPages TaskType = "load_pages" - TypeStatDir TaskType = "stat_dir" - - // File tasks - TypeReadFile TaskType = "read_file" - TypeWriteFile TaskType = "write_file" - TypeStatFile TaskType = "stat_file" - - // Cache tasks - TypeWriteCache TaskType = "write_cache" - TypeReadCache TaskType = "read_cache" - TypeInvalidate TaskType = "invalidate_cache" - - // State persistence - TypeSaveState TaskType = "save_state" - TypeSaveUndo TaskType = "save_undo" + TypeUnknown TaskType = iota + TypeReadFile + TypeReadChunk + TypeStatFile + TypeBuildLineIndex + TypeWriteFile + // Browser task types + TypeReadDir + TypeBuildIndex + TypeLoadIndex + TypeLoadPages + TypeStatDir + // Cache task types + TypeWriteCache + TypeReadCache + TypeInvalidate + // State persistence types + TypeSaveState + TypeSaveUndo ) -// Task represents a unit of work to be executed by a worker. -// Tasks are immutable after creation. -type Task interface { - // Execute performs the task and returns a result. - Execute() Result - - // Priority returns the task priority level. - Priority() Priority - - // TaskID returns a unique identifier for this task. - TaskID() string - - // TaskType returns the type of task (for result routing). - TaskType() TaskType - - // DirPath returns the directory this task operates on, if any. - DirPath() string - - // Context returns the context for this task, used for cancellation. - Context() context.Context - - // Cancel cancels this task if it's still running. - Cancel() - - // Timeout returns the timeout for this task. Zero means no timeout. - Timeout() time.Duration +func (t TaskType) String() string { + switch t { + case TypeReadFile: + return "read_file" + case TypeReadChunk: + return "read_chunk" + case TypeStatFile: + return "stat_file" + case TypeBuildLineIndex: + return "build_line_index" + case TypeWriteFile: + return "write_file" + case TypeReadDir: + return "read_dir" + case TypeBuildIndex: + return "build_index" + case TypeLoadIndex: + return "load_index" + case TypeLoadPages: + return "load_pages" + case TypeStatDir: + return "stat_dir" + case TypeWriteCache: + return "write_cache" + case TypeReadCache: + return "read_cache" + case TypeInvalidate: + return "invalidate_cache" + case TypeSaveState: + return "save_state" + case TypeSaveUndo: + return "save_undo" + default: + return "unknown" + } } -// --- Browser Tasks --- -// ReadDirTask reads directory entries from the filesystem. + +// --- Specific Task Implementations --- + +// ReadChunkTask reads a specific chunk of a file. +type ReadChunkTask struct { + taskID string + Path string + ChunkIdx int + FS FileSystem + ctx context.Context + cancel context.CancelFunc +} + +// NewReadChunkTask creates a new ReadChunkTask. +func NewReadChunkTask(path string, chunkIdx int, fs FileSystem) *ReadChunkTask { + ctx, cancel := context.WithCancel(context.Background()) + return &ReadChunkTask{ + taskID: fmt.Sprintf("readchunk-%d-%s", chunkIdx, filepath.Base(path)), + Path: path, + ChunkIdx: chunkIdx, + FS: fs, + ctx: ctx, + cancel: cancel, + } +} + +func (t *ReadChunkTask) Execute() Result { + // Use ReadFileAt to read only the specific chunk range (not the entire file) + chunkSize := 64 * 1024 // Must match the chunk size used in ChunkedBuffer + start := t.ChunkIdx * chunkSize + + chunk, err := t.FS.ReadFileAt(t.Path, start, chunkSize) + if err != nil { + return Result{TaskID: t.taskID, TaskType: TypeReadChunk, FilePath: t.Path, Success: false, Error: fmt.Errorf("failed to read chunk %d: %w", t.ChunkIdx, err)} + } + + return Result{TaskID: t.taskID, TaskType: TypeReadChunk, FilePath: t.Path, Success: true, Data: chunk} +} + +func (t *ReadChunkTask) Priority() Priority { return HighPriority } +func (t *ReadChunkTask) TaskType() TaskType { return TypeReadChunk } +func (t *ReadChunkTask) TaskID() string { return t.taskID } +func (t *ReadChunkTask) DirPath() string { return filepath.Dir(t.Path) } +func (t *ReadChunkTask) Context() context.Context { return t.ctx } +func (t *ReadChunkTask) Timeout() time.Duration { return 5 * time.Second } +func (t *ReadChunkTask) Cancel() { if t.cancel != nil { t.cancel() } } + +// StatFileTask retrieves file metadata (like size and modification time). +// The plan indicates this replaces the full-file ReadFileTask for opening. +type StatFileTask struct { + taskID string + Path string + FS FileSystem + ctx context.Context + cancel context.CancelFunc +} + +// NewStatFileTask creates a new StatFileTask. +func NewStatFileTask(path string, fs FileSystem) *StatFileTask { + ctx, cancel := context.WithCancel(context.Background()) + return &StatFileTask{ + taskID: fmt.Sprintf("statfile-%s", filepath.Base(path)), + Path: path, + FS: fs, + ctx: ctx, + cancel: cancel, + } +} + +func (t *StatFileTask) Execute() Result { + // In the current plan, this reads the full file content to get size. + // A more optimized version for large files would use os.Stat or similar, + // which returns size directly without reading content. + content, err := t.FS.ReadFile(t.Path) + if err != nil { + return Result{TaskID: t.taskID, TaskType: TypeStatFile, FilePath: t.Path, Success: false, Error: fmt.Errorf("failed to read file for stat: %w", err)} + } + + // We also need mtime for invalidation, but FileInfo might not be directly returned as Data. + // For now, just returning size. + return Result{ + TaskID: t.taskID, TaskType: TypeStatFile, FilePath: t.Path, Success: true, + Data: &FileStat{Path: t.Path, Size: int64(len(content))}, + } +} + +func (t *StatFileTask) Priority() Priority { return MediumPriority } +func (t *StatFileTask) TaskType() TaskType { return TypeStatFile } +func (t *StatFileTask) TaskID() string { return t.taskID } +func (t *StatFileTask) DirPath() string { return filepath.Dir(t.Path) } +func (t *StatFileTask) Context() context.Context { return t.ctx } +func (t *StatFileTask) Timeout() time.Duration { return 5 * time.Second } +func (t *StatFileTask) Cancel() { if t.cancel != nil { t.cancel() } } + +// FileStat holds file metadata. +type FileStat struct { + Path string + Size int64 + // MTime time.Time // Add this if FileSystem.Stat returns it and we need it +} + +// BuildLineIndexTask builds the line index for a file. +type BuildLineIndexTask struct { + taskID string + Path string + FS FileSystem + ctx context.Context + cancel context.CancelFunc +} + +// NewBuildLineIndexTask creates a new BuildLineIndexTask. +func NewBuildLineIndexTask(path string, fs FileSystem) *BuildLineIndexTask { + ctx, cancel := context.WithCancel(context.Background()) + return &BuildLineIndexTask{ + taskID: fmt.Sprintf("buildindex-%s", filepath.Base(path)), + Path: path, + FS: fs, + ctx: ctx, + cancel: cancel, + } +} + +func (t *BuildLineIndexTask) Execute() Result { + content, err := t.FS.ReadFile(t.Path) + if err != nil { + return Result{TaskID: t.taskID, TaskType: TypeBuildLineIndex, FilePath: t.Path, Success: false, Error: fmt.Errorf("failed to read file for index build: %w", err)} + } + + offsets := []int32{0} // Line 0 starts at byte offset 0 + for i := 0; i < len(content); i++ { + if content[i] == '\n' { + offsets = append(offsets, int32(i+1)) + } + } + + lineIndex := types.NewLineIndex(offsets, 0, int64(len(content))) + + return Result{ + TaskID: t.taskID, TaskType: TypeBuildLineIndex, FilePath: t.Path, Success: true, + Data: lineIndex, + } +} + +func (t *BuildLineIndexTask) Priority() Priority { return LowPriority } +func (t *BuildLineIndexTask) TaskType() TaskType { return TypeBuildLineIndex } +func (t *BuildLineIndexTask) TaskID() string { return t.taskID } +func (t *BuildLineIndexTask) DirPath() string { return filepath.Dir(t.Path) } +func (t *BuildLineIndexTask) Context() context.Context { return t.ctx } +func (t *BuildLineIndexTask) Timeout() time.Duration { return 30 * time.Second } +func (t *BuildLineIndexTask) Cancel() { if t.cancel != nil { t.cancel() } } + +// BuildIndexTask builds the directory index for the browser. +type BuildIndexTask struct { + taskID string + Dir string + FS FileSystem + ctx context.Context + cancel context.CancelFunc +} + +// NewBuildIndexTask creates a new BuildIndexTask. +func NewBuildIndexTask(dir string, fs FileSystem) *BuildIndexTask { + ctx, cancel := context.WithCancel(context.Background()) + return &BuildIndexTask{ + taskID: fmt.Sprintf("buildindex-%s", filepath.Base(dir)), + Dir: dir, + FS: fs, + ctx: ctx, + cancel: cancel, + } +} + +func (t *BuildIndexTask) Execute() Result { + entries, err := t.FS.ReadDir(t.Dir) + if err != nil { + return Result{TaskID: t.taskID, TaskType: TypeBuildIndex, Success: false, Error: fmt.Errorf("failed to read directory: %w", err)} + } + return Result{TaskID: t.taskID, TaskType: TypeBuildIndex, Success: true, Data: entries} +} + +func (t *BuildIndexTask) Priority() Priority { return HighPriority } +func (t *BuildIndexTask) TaskType() TaskType { return TypeBuildIndex } +func (t *BuildIndexTask) TaskID() string { return t.taskID } +func (t *BuildIndexTask) DirPath() string { return t.Dir } +func (t *BuildIndexTask) Context() context.Context { return t.ctx } +func (t *BuildIndexTask) Timeout() time.Duration { return 5 * time.Second } +func (t *BuildIndexTask) Cancel() { if t.cancel != nil { t.cancel() } } + +// LoadPagesTask loads directory entries for the given page indices. +type LoadPagesTask struct { + taskID string + Dir string + PageIdxs []int + FS FileSystem + ctx context.Context + cancel context.CancelFunc +} + +// NewLoadPagesTask creates a new LoadPagesTask. +func NewLoadPagesTask(dir string, pageIdxs []int, fs FileSystem) *LoadPagesTask { + ctx, cancel := context.WithCancel(context.Background()) + id := taskIDCounter.Add(1) + return &LoadPagesTask{ + taskID: fmt.Sprintf("loadpages-%s-%d", filepath.Base(dir), id), + Dir: dir, + PageIdxs: pageIdxs, + FS: fs, + ctx: ctx, + cancel: cancel, + } +} + +func (t *LoadPagesTask) Execute() Result { + // In a real implementation, this would read the actual entry data for the pages. + // For now, just return the page indices as success — the browser manager + // will load the actual data from the SortIndex. + return Result{TaskID: t.taskID, TaskType: TypeLoadPages, Success: true, Data: t.PageIdxs} +} + +func (t *LoadPagesTask) Priority() Priority { return HighPriority } +func (t *LoadPagesTask) TaskType() TaskType { return TypeLoadPages } +func (t *LoadPagesTask) TaskID() string { return t.taskID } +func (t *LoadPagesTask) DirPath() string { return t.Dir } +func (t *LoadPagesTask) Context() context.Context { return t.ctx } +func (t *LoadPagesTask) Timeout() time.Duration { return 5 * time.Second } +func (t *LoadPagesTask) Cancel() { if t.cancel != nil { t.cancel() } } + +// ReadDirTask reads directory entries. type ReadDirTask struct { taskID string Dir string FS FileSystem + ctx context.Context + cancel context.CancelFunc } +// NewReadDirTask creates a new ReadDirTask. func NewReadDirTask(dir string, fs FileSystem) *ReadDirTask { + ctx, cancel := context.WithCancel(context.Background()) + id := taskIDCounter.Add(1) return &ReadDirTask{ - taskID: fmt.Sprintf("read_dir_%s_%d", filepath.Base(dir), taskCounter.Add(1)), + taskID: fmt.Sprintf("readdir-%s-%d", filepath.Base(dir), id), Dir: dir, FS: fs, + ctx: ctx, + cancel: cancel, } } func (t *ReadDirTask) Execute() Result { entries, err := t.FS.ReadDir(t.Dir) if err != nil { - return Result{ - TaskID: t.taskID, - TaskType: TypeReadDir, - Success: false, - Error: err, - } - } - return Result{ - TaskID: t.taskID, - TaskType: TypeReadDir, - Success: true, - Data: entries, + return Result{TaskID: t.taskID, TaskType: TypeReadDir, Success: false, Error: fmt.Errorf("failed to read directory: %w", err)} } + return Result{TaskID: t.taskID, TaskType: TypeReadDir, Success: true, Data: entries} } func (t *ReadDirTask) Priority() Priority { return HighPriority } -func (t *ReadDirTask) TaskID() string { return t.taskID } func (t *ReadDirTask) TaskType() TaskType { return TypeReadDir } -func (t *ReadDirTask) DirPath() string { return t.Dir } -func (t *ReadDirTask) Context() context.Context { return context.Background() } -func (t *ReadDirTask) Cancel() {} +func (t *ReadDirTask) TaskID() string { return t.taskID } +func (t *ReadDirTask) DirPath() string { return t.Dir } +func (t *ReadDirTask) Context() context.Context { return t.ctx } func (t *ReadDirTask) Timeout() time.Duration { return 5 * time.Second } +func (t *ReadDirTask) Cancel() { if t.cancel != nil { t.cancel() } } -// BuildIndexTask builds a directory index and writes it to cache. -type BuildIndexTask struct { - taskID string - Dir string - FS FileSystem +// SaveStateTask persists application state. +type SaveStateTask struct { + taskID string + Path string + Content []byte + FS FileSystem + ctx context.Context + cancel context.CancelFunc } -func NewBuildIndexTask(dir string, fs FileSystem) *BuildIndexTask { - return &BuildIndexTask{ - taskID: fmt.Sprintf("build_index_%s_%d", filepath.Base(dir), taskCounter.Add(1)), - Dir: dir, - FS: fs, +// NewSaveStateTask creates a new SaveStateTask. +func NewSaveStateTask(path string, content []byte, fs FileSystem) *SaveStateTask { + ctx, cancel := context.WithCancel(context.Background()) + id := taskIDCounter.Add(1) + return &SaveStateTask{ + taskID: fmt.Sprintf("savestate-%s-%d", filepath.Base(path), id), + Path: path, + Content: content, + FS: fs, + ctx: ctx, + cancel: cancel, } } -func (t *BuildIndexTask) Execute() Result { - // In production, this would read the directory, sort entries, - // compute letter offsets, and write to cache. - // For now, we just signal success with the directory info. - entries, err := t.FS.ReadDir(t.Dir) +func (t *SaveStateTask) Execute() Result { + err := t.FS.WriteFileAtomic(t.Path, t.Content) if err != nil { - return Result{ - TaskID: t.taskID, - TaskType: TypeBuildIndex, - Success: false, - Error: err, - } + return Result{TaskID: t.taskID, TaskType: TypeSaveState, Success: false, Error: fmt.Errorf("failed to save state: %w", err)} } - return Result{ - TaskID: t.taskID, - TaskType: TypeBuildIndex, - Success: true, - Data: entries, + return Result{TaskID: t.taskID, TaskType: TypeSaveState, Success: true} +} + +func (t *SaveStateTask) Priority() Priority { return LowPriority } +func (t *SaveStateTask) TaskType() TaskType { return TypeSaveState } +func (t *SaveStateTask) TaskID() string { return t.taskID } +func (t *SaveStateTask) DirPath() string { return filepath.Dir(t.Path) } +func (t *SaveStateTask) Context() context.Context { return t.ctx } +func (t *SaveStateTask) Timeout() time.Duration { return 5 * time.Second } +func (t *SaveStateTask) Cancel() { if t.cancel != nil { t.cancel() } } + +// SaveUndoTask persists undo stack. +type SaveUndoTask struct { + taskID string + Path string + Content []byte + FS FileSystem + ctx context.Context + cancel context.CancelFunc +} + +// NewSaveUndoTask creates a new SaveUndoTask. +func NewSaveUndoTask(path string, content []byte, fs FileSystem) *SaveUndoTask { + ctx, cancel := context.WithCancel(context.Background()) + id := taskIDCounter.Add(1) + return &SaveUndoTask{ + taskID: fmt.Sprintf("saveundo-%s-%d", filepath.Base(path), id), + Path: path, + Content: content, + FS: fs, + ctx: ctx, + cancel: cancel, } } -func (t *BuildIndexTask) Priority() Priority { return HighPriority } -func (t *BuildIndexTask) TaskID() string { return t.taskID } -func (t *BuildIndexTask) TaskType() TaskType { return TypeBuildIndex } -func (t *BuildIndexTask) DirPath() string { return t.Dir } -func (t *BuildIndexTask) Context() context.Context { return context.Background() } -func (t *BuildIndexTask) Cancel() {} -func (t *BuildIndexTask) Timeout() time.Duration { return 5 * time.Second } - -// LoadIndexTask loads a cached directory index. -type LoadIndexTask struct { - taskID string - Dir string - FS FileSystem -} - -func NewLoadIndexTask(dir string, fs FileSystem) *LoadIndexTask { - return &LoadIndexTask{ - taskID: fmt.Sprintf("load_index_%s_%d", filepath.Base(dir), taskCounter.Add(1)), - Dir: dir, - FS: fs, +func (t *SaveUndoTask) Execute() Result { + err := t.FS.WriteFileAtomic(t.Path, t.Content) + if err != nil { + return Result{TaskID: t.taskID, TaskType: TypeSaveUndo, Success: false, Error: fmt.Errorf("failed to save undo: %w", err)} } + return Result{TaskID: t.taskID, TaskType: TypeSaveUndo, Success: true} } -func (t *LoadIndexTask) Execute() Result { - // In production, this would read the index from cache. - // For now, we just signal success. - return Result{ - TaskID: t.taskID, - TaskType: TypeLoadIndex, - Success: true, - Data: nil, - } -} +func (t *SaveUndoTask) Priority() Priority { return LowPriority } +func (t *SaveUndoTask) TaskType() TaskType { return TypeSaveUndo } +func (t *SaveUndoTask) TaskID() string { return t.taskID } +func (t *SaveUndoTask) DirPath() string { return filepath.Dir(t.Path) } +func (t *SaveUndoTask) Context() context.Context { return t.ctx } +func (t *SaveUndoTask) Timeout() time.Duration { return 5 * time.Second } +func (t *SaveUndoTask) Cancel() { if t.cancel != nil { t.cancel() } } -func (t *LoadIndexTask) Priority() Priority { return HighPriority } -func (t *LoadIndexTask) TaskID() string { return t.taskID } -func (t *LoadIndexTask) TaskType() TaskType { return TypeLoadIndex } -func (t *LoadIndexTask) DirPath() string { return t.Dir } -func (t *LoadIndexTask) Context() context.Context { return context.Background() } -func (t *LoadIndexTask) Cancel() {} -func (t *LoadIndexTask) Timeout() time.Duration { return 5 * time.Second } - -// LoadPagesTask loads specific pages of directory entries from cache. -type LoadPagesTask struct { - taskID string - Dir string - PageIndices []int - FS FileSystem -} - -func NewLoadPagesTask(dir string, pageIndices []int, fs FileSystem) *LoadPagesTask { - return &LoadPagesTask{ - taskID: fmt.Sprintf("load_pages_%s_%d", filepath.Base(dir), taskCounter.Add(1)), - Dir: dir, - PageIndices: pageIndices, - FS: fs, - } -} - -func (t *LoadPagesTask) Execute() Result { - // In production, this would read pages from cache. - return Result{ - TaskID: t.taskID, - TaskType: TypeLoadPages, - Success: true, - Data: t.PageIndices, - } -} - -func (t *LoadPagesTask) Priority() Priority { return HighPriority } -func (t *LoadPagesTask) TaskID() string { return t.taskID } -func (t *LoadPagesTask) TaskType() TaskType { return TypeLoadPages } -func (t *LoadPagesTask) DirPath() string { return t.Dir } -func (t *LoadPagesTask) Context() context.Context { return context.Background() } -func (t *LoadPagesTask) Cancel() {} -func (t *LoadPagesTask) Timeout() time.Duration { return 5 * time.Second } - -// StatDirTask gets directory metadata. -type StatDirTask struct { - taskID string - Dir string - FS FileSystem -} - -func NewStatDirTask(dir string, fs FileSystem) *StatDirTask { - return &StatDirTask{ - taskID: fmt.Sprintf("stat_dir_%s_%d", filepath.Base(dir), taskCounter.Add(1)), - Dir: dir, - FS: fs, - } -} - -func (t *StatDirTask) Execute() Result { - exists := t.FS.DirExists(t.Dir) - return Result{ - TaskID: t.taskID, - TaskType: TypeStatDir, - Success: true, - Data: map[string]bool{"exists": exists}, - } -} - -func (t *StatDirTask) Priority() Priority { return LowPriority } -func (t *StatDirTask) TaskID() string { return t.taskID } -func (t *StatDirTask) TaskType() TaskType { return TypeStatDir } -func (t *StatDirTask) DirPath() string { return t.Dir } -func (t *StatDirTask) Context() context.Context { return context.Background() } -func (t *StatDirTask) Cancel() {} -func (t *StatDirTask) Timeout() time.Duration { return 30 * time.Second } - -// --- File Tasks --- - -// ReadFileTask reads file content. +// ReadFileTask reads the full content of a file. +// Used as a fallback for small files or initial load before chunking is set up. type ReadFileTask struct { taskID string Path string FS FileSystem + ctx context.Context + cancel context.CancelFunc } +// NewReadFileTask creates a new ReadFileTask. func NewReadFileTask(path string, fs FileSystem) *ReadFileTask { + ctx, cancel := context.WithCancel(context.Background()) return &ReadFileTask{ - taskID: fmt.Sprintf("read_file_%s_%d", filepath.Base(path), taskCounter.Add(1)), + taskID: fmt.Sprintf("readfile-%s", filepath.Base(path)), Path: path, FS: fs, + ctx: ctx, + cancel: cancel, } } func (t *ReadFileTask) Execute() Result { content, err := t.FS.ReadFile(t.Path) if err != nil { - return Result{ - TaskID: t.taskID, - TaskType: TypeReadFile, - Success: false, - Error: err, - } - } - return Result{ - TaskID: t.taskID, - TaskType: TypeReadFile, - Success: true, - Data: content, + return Result{TaskID: t.taskID, TaskType: TypeReadFile, FilePath: t.Path, Success: false, Error: fmt.Errorf("failed to read file: %w", err)} } + return Result{TaskID: t.taskID, TaskType: TypeReadFile, FilePath: t.Path, Success: true, Data: content} } func (t *ReadFileTask) Priority() Priority { return HighPriority } -func (t *ReadFileTask) TaskID() string { return t.taskID } func (t *ReadFileTask) TaskType() TaskType { return TypeReadFile } -func (t *ReadFileTask) DirPath() string { return filepath.Dir(t.Path) } -func (t *ReadFileTask) Context() context.Context { return context.Background() } -func (t *ReadFileTask) Cancel() {} +func (t *ReadFileTask) TaskID() string { return t.taskID } +func (t *ReadFileTask) DirPath() string { return filepath.Dir(t.Path) } +func (t *ReadFileTask) Context() context.Context { return t.ctx } func (t *ReadFileTask) Timeout() time.Duration { return 5 * time.Second } +func (t *ReadFileTask) Cancel() { if t.cancel != nil { t.cancel() } } -// WriteFileTask writes file content (auto-save). +// WriteFileTask writes content to a file. type WriteFileTask struct { - taskID string - Path string - FS FileSystem - Data []byte + taskID string + Path string + Content []byte + FS FileSystem + ctx context.Context + cancel context.CancelFunc } -func NewWriteFileTask(path string, data []byte, fs FileSystem) *WriteFileTask { +// NewWriteFileTask creates a new WriteFileTask. +func NewWriteFileTask(path string, content []byte, fs FileSystem) *WriteFileTask { + ctx, cancel := context.WithCancel(context.Background()) return &WriteFileTask{ - taskID: fmt.Sprintf("write_file_%s_%d", filepath.Base(path), taskCounter.Add(1)), - Path: path, - FS: fs, - Data: data, + taskID: fmt.Sprintf("writefile-%s", filepath.Base(path)), + Path: path, + Content: content, + FS: fs, + ctx: ctx, + cancel: cancel, } } func (t *WriteFileTask) Execute() Result { - err := t.FS.WriteFileAtomic(t.Path, t.Data) + err := t.FS.WriteFile(t.Path, t.Content) if err != nil { - return Result{ - TaskID: t.taskID, - 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 + return Result{TaskID: t.taskID, TaskType: TypeWriteFile, FilePath: t.Path, Success: false, Error: fmt.Errorf("failed to write file: %w", err)} } + return Result{TaskID: t.taskID, TaskType: TypeWriteFile, FilePath: t.Path, Success: true} } func (t *WriteFileTask) Priority() Priority { return LowPriority } -func (t *WriteFileTask) TaskID() string { return t.taskID } func (t *WriteFileTask) TaskType() TaskType { return TypeWriteFile } -func (t *WriteFileTask) DirPath() string { return filepath.Dir(t.Path) } -func (t *WriteFileTask) Context() context.Context { return context.Background() } -func (t *WriteFileTask) Cancel() {} -func (t *WriteFileTask) Timeout() time.Duration { return 30 * time.Second } +func (t *WriteFileTask) TaskID() string { return t.taskID } +func (t *WriteFileTask) DirPath() string { return filepath.Dir(t.Path) } +func (t *WriteFileTask) Context() context.Context { return t.ctx } +func (t *WriteFileTask) Timeout() time.Duration { return 5 * time.Second } +func (t *WriteFileTask) Cancel() { if t.cancel != nil { t.cancel() } } -// --- Cache Tasks --- +// --- Mock File System (for testing/development) --- +// MockFS implements the pool.FileSystem interface for testing. -// WriteCacheTask writes cache data. -type WriteCacheTask struct { - taskID string - Path string - FS FileSystem - Data []byte +type MockFS struct { + files map[string][]byte } -func NewWriteCacheTask(path string, data []byte, fs FileSystem) *WriteCacheTask { - return &WriteCacheTask{ - taskID: fmt.Sprintf("write_cache_%s_%d", filepath.Base(path), taskCounter.Add(1)), - Path: path, - FS: fs, - Data: data, +func NewMockFS() *MockFS { + return &MockFS{ + files: make(map[string][]byte), } } -func (t *WriteCacheTask) Execute() Result { - err := t.FS.WriteFile(t.Path, t.Data) - if err != nil { - return Result{ - TaskID: t.taskID, - TaskType: TypeWriteCache, - Success: false, - Error: err, +func (m *MockFS) ReadFile(path string) ([]byte, error) { + content, ok := m.files[path] + if !ok { + return nil, fmt.Errorf("file not found: %s", path) + } + return content, nil +} + +func (m *MockFS) ReadFileAt(path string, offset, size int) ([]byte, error) { + content, ok := m.files[path] + if !ok { + return nil, fmt.Errorf("file not found: %s", path) + } + if offset >= len(content) { + return []byte{}, nil + } + end := offset + size + if end > len(content) { + end = len(content) + } + result := make([]byte, len(content[offset:end])) + copy(result, content[offset:end]) + return result, nil +} + +func (m *MockFS) WriteFile(path string, content []byte) error { + m.files[path] = content + return nil +} + +func (m *MockFS) WriteFileAtomic(path string, content []byte) error { + m.files[path] = content + return nil +} + +func (m *MockFS) DeleteFile(path string) error { + delete(m.files, path) + return nil +} + +func (m *MockFS) CreateDir(path string) error { + return nil +} + +func (m *MockFS) DirExists(path string) bool { + _, ok := m.files[path] + return ok +} + +func (m *MockFS) FileExists(path string) bool { + _, ok := m.files[path] + return ok +} + +// mockFileInfo implements io.FileInfo for MockFS. +type mockFileInfo struct { + name string + size int64 + mode uint32 + modTime time.Time +} + +func (f *mockFileInfo) Name() string { return f.name } +func (f *mockFileInfo) Size() int64 { return f.size } +func (f *mockFileInfo) Mode() os.FileMode { return os.FileMode(f.mode) } +func (f *mockFileInfo) ModTime() time.Time { return f.modTime } +func (f *mockFileInfo) IsDir() bool { return false } +func (f *mockFileInfo) Sys() any { return nil } + +// mockDirEntry implements types.DirEntry for MockFS ReadDir. +type mockDirEntry struct { + name string + isDir bool +} + +func (e *mockDirEntry) Name() string { return e.name } +func (e *mockDirEntry) IsDir() bool { return e.isDir } +func (e *mockDirEntry) Info() (os.FileInfo, error) { + return &mockFileInfo{name: e.name, size: 0}, nil +} + +func (m *MockFS) ReadDir(path string) ([]types.DirEntry, error) { + var entries []types.DirEntry + for p := range m.files { + if filepath.Dir(p) == path { + entries = append(entries, &mockDirEntry{name: filepath.Base(p), isDir: false}) } } - return Result{ - TaskID: t.taskID, - TaskType: TypeWriteCache, - Success: true, - } + return entries, nil } -func (t *WriteCacheTask) Priority() Priority { return LowPriority } -func (t *WriteCacheTask) TaskID() string { return t.taskID } -func (t *WriteCacheTask) TaskType() TaskType { return TypeWriteCache } -func (t *WriteCacheTask) DirPath() string { return filepath.Dir(t.Path) } -func (t *WriteCacheTask) Context() context.Context { return context.Background() } -func (t *WriteCacheTask) Cancel() {} -func (t *WriteCacheTask) Timeout() time.Duration { return 30 * time.Second } +var _ FileSystem = (*MockFS)(nil) // Compile-time interface check -// --- State Persistence Tasks --- -// SaveStateTask persists application state. -type SaveStateTask struct { - taskID string - Path string - FS FileSystem - Data []byte -} -func NewSaveStateTask(path string, data []byte, fs FileSystem) *SaveStateTask { - return &SaveStateTask{ - taskID: fmt.Sprintf("save_state_%s_%d", filepath.Base(path), taskCounter.Add(1)), - Path: path, - FS: fs, - Data: data, - } -} - -func (t *SaveStateTask) Execute() Result { - err := t.FS.WriteFile(t.Path, t.Data) - if err != nil { - return Result{ - TaskID: t.taskID, - TaskType: TypeSaveState, - Success: false, - Error: err, - } - } - return Result{ - TaskID: t.taskID, - TaskType: TypeSaveState, - Success: true, - } -} - -func (t *SaveStateTask) Priority() Priority { return LowPriority } -func (t *SaveStateTask) TaskID() string { return t.taskID } -func (t *SaveStateTask) TaskType() TaskType { return TypeSaveState } -func (t *SaveStateTask) DirPath() string { return filepath.Dir(t.Path) } -func (t *SaveStateTask) Context() context.Context { return context.Background() } -func (t *SaveStateTask) Cancel() {} -func (t *SaveStateTask) Timeout() time.Duration { return 30 * time.Second } - -// SaveUndoTask persists undo stack. -type SaveUndoTask struct { - taskID string - Path string - FS FileSystem - Data []byte -} - -func NewSaveUndoTask(path string, data []byte, fs FileSystem) *SaveUndoTask { - return &SaveUndoTask{ - taskID: fmt.Sprintf("save_undo_%s_%d", filepath.Base(path), taskCounter.Add(1)), - Path: path, - FS: fs, - Data: data, - } -} - -func (t *SaveUndoTask) Execute() Result { - err := t.FS.WriteFile(t.Path, t.Data) - if err != nil { - return Result{ - TaskID: t.taskID, - TaskType: TypeSaveUndo, - Success: false, - Error: err, - } - } - return Result{ - TaskID: t.taskID, - TaskType: TypeSaveUndo, - Success: true, - } -} - -func (t *SaveUndoTask) Priority() Priority { return LowPriority } -func (t *SaveUndoTask) TaskID() string { return t.taskID } -func (t *SaveUndoTask) TaskType() TaskType { return TypeSaveUndo } -func (t *SaveUndoTask) DirPath() string { return filepath.Dir(t.Path) } -func (t *SaveUndoTask) Context() context.Context { return context.Background() } -func (t *SaveUndoTask) Cancel() {} -func (t *SaveUndoTask) Timeout() time.Duration { return 30 * time.Second } diff --git a/internal/io/pool/task_test.go b/internal/io/pool/task_test.go index b989321..e98a9db 100644 --- a/internal/io/pool/task_test.go +++ b/internal/io/pool/task_test.go @@ -307,8 +307,8 @@ func TestTask_TaskType_String(t *testing.T) { } for _, tt := range tests { - if string(tt.taskType) != tt.want { - t.Errorf("TaskType %v = %q, want %q", tt.taskType, string(tt.taskType), tt.want) + if tt.taskType.String() != tt.want { + t.Errorf("TaskType %v = %q, want %q", tt.taskType, tt.taskType.String(), tt.want) } } } diff --git a/internal/io/pool/types/types.go b/internal/io/pool/types/types.go index 7836b8b..4a8d23a 100644 --- a/internal/io/pool/types/types.go +++ b/internal/io/pool/types/types.go @@ -1,6 +1,9 @@ package types -import "os" +import ( + "fmt" + "os" +) // DirEntry interface defines the structure for directory entries. type DirEntry interface { @@ -8,3 +11,39 @@ type DirEntry interface { IsDir() bool Info() (os.FileInfo, error) } + +// LineIndex maps line numbers to byte offsets within a file. +// Built as a low-priority background task; cached on disk. +type LineIndex struct { + Offsets []int32 // byte offset of each line start (line 0 = 0) + Mtime int64 // file mtime at index build time + Size int64 // file size at index build time +} + +// NewLineIndex creates a LineIndex from a list of byte offsets. +func NewLineIndex(offsets []int32, mtime int64, size int64) *LineIndex { + return &LineIndex{ + Offsets: offsets, + Mtime: mtime, + Size: size, + } +} + +// LineCount returns the number of lines in the index. +func (li *LineIndex) LineCount() int { + return len(li.Offsets) +} + +// ByteOffset returns the byte offset of the given line number. +// Returns 0 if the line number is out of range. +func (li *LineIndex) ByteOffset(line int) int { + if line < 0 || line >= len(li.Offsets) { + return 0 + } + return int(li.Offsets[line]) +} + +// String returns a string representation of the LineIndex. +func (li *LineIndex) String() string { + return fmt.Sprintf("LineIndex{lines=%d, size=%d, mtime=%d}", len(li.Offsets), li.Size, li.Mtime) +} diff --git a/internal/test/e2e/edit_lifecycle_test.go b/internal/test/e2e/edit_lifecycle_test.go new file mode 100644 index 0000000..3b1ca17 --- /dev/null +++ b/internal/test/e2e/edit_lifecycle_test.go @@ -0,0 +1,90 @@ +package e2e_test + +import ( + "testing" + "time" + + "pad/internal/editor" + "pad/internal/test/e2e" + "pad/internal/ui" +) + +func TestEditLifecycle(t *testing.T) { + h := e2e.NewHarnessWithDefaults() + defer h.Cleanup() + + // 1. Go to browser page + editor.GoToBrowser(nil) + time.Sleep(200 * time.Millisecond) + + // 2. Open File + filename := "/notes.txt" + editor.OpenFile(filename) + + // Wait for file to load + success := false + for i := 0; i < 20; i++ { + if h.State().Editor.ChunkedBuffer != nil && h.State().Editor.ChunkedBuffer.FileLen() > 0 { + success = true + break + } + time.Sleep(100 * time.Millisecond) + } + if !success { + t.Fatal("Timed out waiting for file to load") + } + + // 3. Edit File + // Original content of notes.txt is 198 bytes, let's append " UPDATED" + editor.HandleInsert(" UPDATED") + time.Sleep(100 * time.Millisecond) + + // 4. Close File (Go back to browser) + // This will trigger FlushAll() + editor.GoToBrowser(nil) + time.Sleep(500 * time.Millisecond) // Ensure it had time to process close + + // 5. Re-open File + editor.OpenFile(filename) + time.Sleep(1000 * time.Millisecond) // Wait for re-open and load + + // 6. Verify Edits + fullContent, err := h.State().Editor.ChunkedBuffer.FullContent() + if err != nil { + t.Fatalf("Failed to reconstruct content: %v", err) + } + if len(fullContent) <= 198 { + t.Errorf("Content did not persist. Length: %d, expected > 198", len(fullContent)) + } + + // 7. Verify Size on Screen (check bottom bar label) + frame := e2e.GetLastFrame(h) + var sizeText string + for _, elem := range frame { + if container, ok := elem.(ui.Container); ok { + // The bottom bar is a container, look for labels inside + for _, child := range container.Children { + if label, ok := child.(ui.Label); ok { + // The cursor position text is in the middle, containing "/" + if len(label.Text) > 0 && label.Text[0] != '/' && contains(label.Text, "/") { + sizeText = label.Text + } + } + } + } + } + + expectedText := "0 / 206" // 198 original + " UPDATED" (8) = 206, cursor at top + if sizeText != expectedText { + t.Errorf("Bottom bar size label mismatch: got %q, expected %q", sizeText, expectedText) + } +} + +func contains(s, substr string) bool { + for i := 0; i < len(s); i++ { + if s[i] == substr[0] { + return true + } + } + return false +}