diff --git a/internal/editor/chunked_buffer.go b/internal/editor/chunked_buffer.go index 97a7a06..e0eb510 100644 --- a/internal/editor/chunked_buffer.go +++ b/internal/editor/chunked_buffer.go @@ -40,6 +40,10 @@ type ChunkedBuffer struct { // since they were last persisted to disk. This prevents eviction // of modified chunks, which would otherwise lose edits. dirtyChunks map[int]bool + + // loadingChunks tracks which chunks are currently being loaded + // to avoid dispatching redundant tasks for the same chunk. + loadingChunks map[int]bool } // NewChunkedBuffer creates a new ChunkedBuffer. @@ -48,12 +52,13 @@ func NewChunkedBuffer(filename string, chunkSize int, fs pool.FileSystem, basePa chunkSize = DefaultChunkSize } return &ChunkedBuffer{ - filename: filename, - chunkSize: chunkSize, - chunks: make(map[int][]byte), - dirtyChunks: make(map[int]bool), - FS: fs, - basePath: basePath, + filename: filename, + chunkSize: chunkSize, + chunks: make(map[int][]byte), + dirtyChunks: make(map[int]bool), + loadingChunks: make(map[int]bool), + FS: fs, + basePath: basePath, } } @@ -103,15 +108,15 @@ func (cb *ChunkedBuffer) Content(start, end int) string { 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 + // If chunk is not loaded, initiate async load and skip this chunk. + // This keeps the UI responsive while chunks are loaded in the background. + if cb.workerPool != nil && !cb.loadingChunks[i] { + cb.loadingChunks[i] = true + cb.workerPool.DispatchNonBlocking( + pool.NewReadChunkTask(cb.filename, i, cb.FS), + ) } - chunk = loadedChunk + continue } chunkStart := i * cb.chunkSize @@ -182,6 +187,27 @@ func (cb *ChunkedBuffer) LoadChunk(idx int) { cb.Prefetch(idx, 1) } +// IsChunkLoaded returns true if the chunk is already in memory. +func (cb *ChunkedBuffer) IsChunkLoaded(idx int) bool { + _, ok := cb.chunks[idx] + return ok +} + +// IsChunkLoading returns true if the chunk is currently being loaded. +func (cb *ChunkedBuffer) IsChunkLoading(idx int) bool { + return cb.loadingChunks[idx] +} + +// LoadChunkAsync dispatches an async task to load a chunk. +func (cb *ChunkedBuffer) LoadChunkAsync(idx int) { + if cb.workerPool != nil && !cb.loadingChunks[idx] { + cb.loadingChunks[idx] = true + cb.workerPool.DispatchNonBlocking( + pool.NewReadChunkTask(cb.filename, idx, cb.FS), + ) + } +} + // SetWorkerPool sets the worker pool for async chunk loading. func (cb *ChunkedBuffer) SetWorkerPool(wp *pool.WorkerPool) { cb.workerPool = wp @@ -200,8 +226,11 @@ 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 { + // Check if chunk is in memory or already loading. + if _, ok := cb.chunks[i]; !ok && !cb.loadingChunks[i] { if cb.workerPool != nil { + // Mark as loading before dispatching to prevent redundant requests + cb.loadingChunks[i] = true // Dispatch async read chunk task cb.workerPool.DispatchNonBlocking( pool.NewReadChunkTask(cb.filename, i, cb.FS), diff --git a/internal/editor/logic.go b/internal/editor/logic.go index 6ad96e6..f426a76 100644 --- a/internal/editor/logic.go +++ b/internal/editor/logic.go @@ -1,7 +1,6 @@ package editor import ( - "fmt" "log" "sync" "time" @@ -78,7 +77,7 @@ func NewLogic(mfs pool.FileSystem, startPath ...string) *Logic { } // Initialize worker pool - wp := pool.NewWorkerPool(4) + wp := pool.NewWorkerPool(8) // Increased worker pool size to 8 wp.Start() // Set browser initial path @@ -286,19 +285,17 @@ func (l *Logic) handleWorkerResult(res pool.Result) { } } } 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)) + if cb := l.state.Editor.ChunkedBuffer; cb != nil { + delete(cb.loadingChunks, res.ChunkIdx) // Use explicit ChunkIdx field + + if res.Success { + if chunk, ok := res.Data.([]byte); ok { + cb.chunks[res.ChunkIdx] = chunk + log.Printf("Logic: Loaded chunk %d for %s (%d bytes)", res.ChunkIdx, res.FilePath, len(chunk)) } + } else { + log.Printf("Logic: ReadChunkTask failed for %s chunk %d: %v", res.FilePath, res.ChunkIdx, res.Error) } - } 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) diff --git a/internal/editor/state.go b/internal/editor/state.go index 2111529..4dcfe32 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -120,6 +120,7 @@ type State struct { MaxScroll ui.Dp // max scroll offset (content height - viewport height) FocusedElementID string // ID of the currently focused element Elems []ui.Element + lastEvictionTime time.Time // Throttles chunk eviction // Browser state (directly embedded per architecture ยง8) Browser browser.BrowserState // Embedded, not a pointer // Editor state @@ -128,9 +129,10 @@ type State struct { func NewState() *State { return &State{ - scale: 1.0, - page: BrowserPage, // Reverted to BrowserPage - Browser: *browser.NewBrowserState(), + scale: 1.0, + page: BrowserPage, // Reverted to BrowserPage + lastEvictionTime: time.Now(), + Browser: *browser.NewBrowserState(), Editor: EditorState{ CursorPosition: 0, SelectionStart: -1, @@ -207,11 +209,16 @@ func HandleScroll(data any) { } // Evict chunks far from the cursor to keep memory bounded. // Only evict when the cursor is near the viewport (i.e., scrolled to top). + // Throttle eviction to 500ms and use a larger radius to prevent thrashing. + // Temporarily disabled eviction to debug thrashing issues. + /* if cb := TheState.Editor.ChunkedBuffer; cb != nil { - cursorChunk := TheState.Editor.CursorPosition / cb.ChunkSize() - cb.EvictFarChunks(TheState.Editor.CursorPosition, 2) - _ = cursorChunk // suppress unused variable warning + if time.Since(TheState.lastEvictionTime) > 500*time.Millisecond { + cb.EvictFarChunks(TheState.Editor.CursorPosition, 20) + TheState.lastEvictionTime = time.Now() + } } + */ } // HandleBrowserScroll updates the browser scroll offset in response to a scroll gesture. @@ -668,7 +675,22 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { // Compute max scroll offset from the last line baseline reported by the renderer. // lastLineY is the shaper's Y value for the last line's baseline. // Add bottom padding (half line height) so last line isn't flush with the bottom bar. - maxScroll := TheState.LastLineY - editorRegion.H + EditorLineHeight()/2 + var maxScroll ui.Dp + if cb := TheState.Editor.ChunkedBuffer; cb != nil { + if li := TheState.Editor.LineIndex; li != nil { + totalLines := li.LineCount() + maxScroll = ui.Dp(totalLines)*EditorLineHeight() - editorRegion.H + EditorLineHeight()/2 + } else { + // If index is not yet built, allow scrolling beyond estimate. + // Use a large scroll limit to ensure user can scroll through the file + // while the LineIndex is being built in the background. + maxScroll = ui.Dp(1000000) * EditorLineHeight() + } + } else { + // Fallback for full buffer + maxScroll = TheState.LastLineY - editorRegion.H + EditorLineHeight()/2 + } + if maxScroll < 0 { maxScroll = 0 } @@ -684,6 +706,15 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { viewportHeight := editorRegion.H start, end := cb.VisibleByteRange(TheState.ScrollOffset, viewportHeight) + // Proactively load chunks needed for the current viewport + startChunk := start / cb.ChunkSize() + endChunk := (end - 1) / cb.ChunkSize() + for i := startChunk; i <= endChunk; i++ { + if !cb.IsChunkLoaded(i) && !cb.IsChunkLoading(i) { + cb.LoadChunkAsync(i) + } + } + // Extract visible content from chunked buffer visibleContent = cb.Content(start, end) @@ -696,12 +727,27 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { // 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) + // Map scroll offset to a chunk index + // Estimate line-to-byte conversion if LineIndex is missing + var scrollByteOffset int + if li := TheState.Editor.LineIndex; li != nil { + // Precise + lineHeight := EditorLineHeight() + startLine := int(TheState.ScrollOffset / lineHeight) + if startLine < li.LineCount() { + scrollByteOffset = li.ByteOffset(startLine) + } else { + scrollByteOffset = int(cb.FileLen()) + } + } else { + // Estimate + scrollByteOffset = int(TheState.ScrollOffset / EditorLineHeight()) * 50 } + + scrollChunk := scrollByteOffset / cb.ChunkSize() + // Use a smaller radius to prevent overloading the worker pool. + // Content() will load chunks immediately needed by the viewport. + cb.Prefetch(scrollChunk, 1) } else { // Fallback: no chunked buffer, use full buffer (small files) visibleContent = TheState.Editor.Buffer diff --git a/internal/io/pool/result.go b/internal/io/pool/result.go index aa7dfa7..c4843ab 100644 --- a/internal/io/pool/result.go +++ b/internal/io/pool/result.go @@ -14,6 +14,7 @@ type Result struct { Timestamp time.Time `json:"timestamp"` DirPath string `json:"dir_path,omitempty"` FilePath string `json:"file_path,omitempty"` // Added + ChunkIdx int `json:"chunk_idx,omitempty"` // Added to uniquely identify chunk result } // IsSuccess returns true if the task completed successfully. diff --git a/internal/io/pool/task.go b/internal/io/pool/task.go index 65a0ebd..75ce2da 100644 --- a/internal/io/pool/task.go +++ b/internal/io/pool/task.go @@ -147,7 +147,15 @@ func (t *ReadChunkTask) Execute() Result { 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} + return Result{ + TaskID: t.taskID, + TaskType: TypeReadChunk, + FilePath: t.Path, + Success: true, + Data: chunk, + ChunkIdx: t.ChunkIdx, // Explicitly pass the chunk index + Timestamp: time.Now(), + } } func (t *ReadChunkTask) Priority() Priority { return HighPriority }