Fix to chunk loading policy.

This commit is contained in:
Greg Pomerantz 2026-06-05 18:06:17 -04:00
parent b224bbe85a
commit 4052769813
5 changed files with 122 additions and 41 deletions

View File

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

View File

@ -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-<idx>-<basename>"
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)

View File

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

View File

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

View File

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