editor: fix viewport-on-open, chunked-buffer drift, add size guard (Phase 3 code)

- Viewport: swallow the opening tap/scroll (justOpenedAt window) and
  re-clamp ScrollOffset to [0,MaxScroll] in EditorLayout so a short file
  never opens past its content (blank viewport).
- Chunked buffer: replace the fixed i*chunkSize slot model (which drifted
  after length-changing edits and could re-read stale disk for shifted
  tail chunks) with an ordered chunk slice + prefix-sum byte offsets.
  In-range files now load fully on open (SetContent), so there is no lazy
  load and no stale-disk re-read. Edits splice only the affected chunk(s).
- Size guard: files > MaxEditableFileSize (50 MB) show a 'too large to
  edit' notice instead of loading; the browser still lists them. Edit
  handlers (KeyDown/ReplaceRange) are no-ops for too-large files.
- Tests: rewrite chunked_buffer_test.go for prefix-sum correctness (insert/
  delete across chunk boundaries, rune->byte after edit); fix the large-file
  e2e expectation to the shift-correct ground truth.
This commit is contained in:
Greg Pomerantz 2026-08-16 10:35:10 -04:00
parent 4cfabec7a4
commit 3460ef3993
6 changed files with 502 additions and 556 deletions

View File

@ -2,7 +2,6 @@ package editor
import ( import (
"bytes" "bytes"
"fmt"
"sort" "sort"
"pad/internal/io/pool" "pad/internal/io/pool"
@ -12,38 +11,61 @@ import (
const ( const (
DefaultChunkSize = 64 * 1024 // 64 KB DefaultChunkSize = 64 * 1024 // 64 KB
// MaxEditableFileSize is the largest file the editor will open for editing.
// In-range files are loaded fully into memory (see Phase 3): the chunked
// buffer keeps the raw bytes (~file size) resident and the renderer
// virtualizes the glyph layout to the visible window. Files above this are
// rejected with a "too large to edit" state (the browser can still list
// them). Set from on-device measurement (see doc/development_plan.md §5).
MaxEditableFileSize = 50 * 1024 * 1024 // 50 MB
) )
// ChunkedBuffer provides chunked access to a file's content. // ChunkedBuffer provides chunked access to a file's content.
// Only chunks near the cursor or viewport are kept in memory. //
// Model (Phase 3): for in-range files the ENTIRE file is loaded into memory on
// open and split into ordered chunks (see SetContent). Each chunk keeps its
// own length; the byte offset of a chunk is the sum of the lengths of the
// chunks before it (a prefix sum), NOT a fixed i*chunkSize slot. This is what
// makes edits correct: an insert/delete changes a chunk's length and the
// prefix sums automatically shift every later chunk, so byte->chunk mapping
// stays exact. Because every chunk is resident, there is no lazy loading and
// therefore no stale-disk re-read (the old fixed-slot model could re-read a
// shifted tail chunk from disk and clobber in-memory edits).
//
// Chunks may grow/shrink with edits and are not rebalanced; that is fine for
// correctness (prefix sums) and keeps edits local to the affected chunk(s)
// rather than copying the whole file.
type ChunkedBuffer struct { type ChunkedBuffer struct {
filename string filename string
chunkSize int // e.g., 64 * 1024 (64 KB) chunkSize int // target chunk size (e.g. 64 KB); actual chunks may vary
fileLen int64 // total file length (known from stat) fileLen int64 // total content length
chunks map[int][]byte // chunkIndex → []byte chunks [][]byte // ordered; chunks[i] is the i-th chunk
dirty bool // true if buffer has been modified dirty bool // true if buffer has been modified
FS pool.FileSystem // filesystem for reading chunks FS pool.FileSystem // filesystem for reads
basePath string // base path for file resolution basePath string // base path for file resolution
// line index is built asynchronously // line index is built asynchronously
LineIndex *types.LineIndex LineIndex *types.LineIndex
// workerPool is set by the logic goroutine after the buffer is created. // workerPool is retained for API compatibility; in-range files load fully
// Used for async prefetch of chunks. // up front, so chunk loading no longer dispatches worker tasks.
workerPool *pool.WorkerPool workerPool *pool.WorkerPool
// lastPrefetchedChunk tracks the last chunk that was prefetched, // lastPrefetchedChunk tracks the last chunk that was prefetched, so callers
// so we can avoid redundant prefetches on the same position. // can avoid redundant work. Kept for compatibility.
lastPrefetchedChunk int lastPrefetchedChunk int
// dirtyChunks tracks which individual chunks have been modified // dirtyChunks tracks which individual chunks have been modified since they
// since they were last persisted to disk. This prevents eviction // were last persisted to disk.
// of modified chunks, which would otherwise lose edits.
dirtyChunks map[int]bool dirtyChunks map[int]bool
// loadingChunks tracks which chunks are currently being loaded // loadingChunks is retained for API compatibility; it is always empty for
// to avoid dispatching redundant tasks for the same chunk. // in-range files (no lazy loading).
loadingChunks map[int]bool loadingChunks map[int]bool
// fullyLoaded is true once SetContent has populated all chunks; the buffer
// never falls back to disk afterwards.
fullyLoaded bool
} }
// NewChunkedBuffer creates a new ChunkedBuffer. // NewChunkedBuffer creates a new ChunkedBuffer.
@ -54,7 +76,8 @@ func NewChunkedBuffer(filename string, chunkSize int, fs pool.FileSystem, basePa
return &ChunkedBuffer{ return &ChunkedBuffer{
filename: filename, filename: filename,
chunkSize: chunkSize, chunkSize: chunkSize,
chunks: make(map[int][]byte), fileLen: 0,
chunks: nil,
dirtyChunks: make(map[int]bool), dirtyChunks: make(map[int]bool),
loadingChunks: make(map[int]bool), loadingChunks: make(map[int]bool),
FS: fs, FS: fs,
@ -62,14 +85,15 @@ func NewChunkedBuffer(filename string, chunkSize int, fs pool.FileSystem, basePa
} }
} }
// SetFileSize sets the total file length. // SetFileSize sets the total file length (used before SetContent, e.g. from a
// stat). It only applies when the buffer is not dirty.
func (cb *ChunkedBuffer) SetFileSize(length int64) { func (cb *ChunkedBuffer) SetFileSize(length int64) {
if !cb.dirty { if !cb.dirty {
cb.fileLen = length cb.fileLen = length
} }
} }
// FileLen returns the total file length. // FileLen returns the total content length.
func (cb *ChunkedBuffer) FileLen() int64 { func (cb *ChunkedBuffer) FileLen() int64 {
return cb.fileLen return cb.fileLen
} }
@ -79,18 +103,60 @@ func (cb *ChunkedBuffer) Filename() string {
return cb.filename return cb.filename
} }
// ChunkSize returns the chunk size. // ChunkSize returns the target chunk size.
func (cb *ChunkedBuffer) ChunkSize() int { func (cb *ChunkedBuffer) ChunkSize() int {
return cb.chunkSize return cb.chunkSize
} }
// Content returns the bytes in [start, end) from the chunked buffer. // SetContent replaces the buffer content with data, split into ordered chunks
// It loads missing chunks on demand. // of at most chunkSize. This is the on-open load path for in-range files: the
// whole file becomes resident, so there is no lazy loading and no risk of
// re-reading stale disk data after an edit.
func (cb *ChunkedBuffer) SetContent(data []byte) {
cb.chunks = nil
for start := 0; start < len(data); start += cb.chunkSize {
end := start + cb.chunkSize
if end > len(data) {
end = len(data)
}
chunk := make([]byte, end-start)
copy(chunk, data[start:end])
cb.chunks = append(cb.chunks, chunk)
}
if len(data) == 0 {
cb.chunks = [][]byte{}
}
cb.fileLen = int64(len(data))
cb.dirty = false
cb.dirtyChunks = make(map[int]bool)
cb.fullyLoaded = true
}
// chunkForPos returns the index of the chunk that contains byte offset pos and
// the offset of pos within that chunk. If pos is at or past the end, it returns
// the last chunk and an offset at its end (so callers can append). If the
// buffer is empty it returns (-1, 0).
func (cb *ChunkedBuffer) chunkForPos(pos int) (int, int) {
offset := 0
for i, chunk := range cb.chunks {
if pos < offset+len(chunk) {
return i, pos - offset
}
offset += len(chunk)
}
if n := len(cb.chunks); n > 0 {
return n - 1, len(cb.chunks[n-1])
}
return -1, 0
}
// Content returns the bytes in [start, end) from the chunked buffer. Chunks are
// walked by their actual (prefix-sum) offsets, so the mapping stays correct
// after length-changing edits.
func (cb *ChunkedBuffer) Content(start, end int) string { func (cb *ChunkedBuffer) Content(start, end int) string {
if cb.fileLen == 0 { if cb.fileLen == 0 {
return "" return ""
} }
// Clamp range to file bounds
if start < 0 { if start < 0 {
start = 0 start = 0
} }
@ -100,31 +166,20 @@ func (cb *ChunkedBuffer) Content(start, end int) string {
if start >= end { if start >= end {
return "" return ""
} }
startChunk := start / cb.chunkSize
endChunk := (end - 1) / cb.chunkSize
var buf bytes.Buffer var buf bytes.Buffer
for i := startChunk; i <= endChunk; i++ { offset := 0
chunk, ok := cb.chunks[i] for _, chunk := range cb.chunks {
if !ok { chunkStart := offset
// If chunk is not loaded, initiate async load and skip this chunk. chunkEnd := offset + len(chunk)
// This keeps the UI responsive while chunks are loaded in the background. offset = chunkEnd
if cb.workerPool != nil && !cb.loadingChunks[i] { if chunkEnd <= start {
cb.loadingChunks[i] = true
cb.workerPool.DispatchNonBlocking(
pool.NewReadChunkTask(cb.filename, i, cb.FS),
)
}
continue continue
} }
if chunkStart >= end {
chunkStart := i * cb.chunkSize break
chunkEnd := chunkStart + len(chunk) }
segStart := max(start, chunkStart) - chunkStart segStart := max(start, chunkStart) - chunkStart
segEnd := min(end, chunkEnd) - chunkStart segEnd := min(end, chunkEnd) - chunkStart
if segStart < segEnd { if segStart < segEnd {
buf.Write(chunk[segStart:segEnd]) buf.Write(chunk[segStart:segEnd])
} }
@ -132,49 +187,21 @@ func (cb *ChunkedBuffer) Content(start, end int) string {
return buf.String() return buf.String()
} }
// chunkSync returns the bytes of chunk idx, synchronously. It prefers the
// in-memory (possibly dirty) copy and only reads from disk for clean, evicted
// chunks. Calling loadChunk directly on a dirty chunk would clobber the
// in-memory edit with stale disk data, so the map is checked first.
func (cb *ChunkedBuffer) chunkSync(idx int) ([]byte, error) {
if chunk, ok := cb.chunks[idx]; ok {
return chunk, nil
}
if cb.dirtyChunks[idx] {
return nil, fmt.Errorf("chunk %d is dirty but not in memory", idx)
}
return cb.loadChunk(idx)
}
// RuneIndexToByte returns the byte offset of the n-th rune (0-indexed) in the // RuneIndexToByte returns the byte offset of the n-th rune (0-indexed) in the
// buffer. The IME addresses text in rune indices while the buffer is // buffer. The IME addresses text in rune indices while the buffer is
// byte-based, so this bridges the two. It scans chunk by chunk and stops as // byte-based, so this bridges the two. It walks chunks by actual length and
// soon as the n-th rune is found, so it reads only up to the caret (a few // stops as soon as the n-th rune is found. If n is at or past the end it
// hundred KB for a typical position) rather than the whole file. If n is at // returns the content length.
// or past the end, it returns the file length. On a chunk read error it
// returns the byte offset scanned so far (a best-effort position).
func (cb *ChunkedBuffer) RuneIndexToByte(n int) int { func (cb *ChunkedBuffer) RuneIndexToByte(n int) int {
if n <= 0 { if n <= 0 {
return 0 return 0
} }
fileLen := int(cb.fileLen) if cb.fileLen == 0 {
if fileLen == 0 {
return 0 return 0
} }
runes := 0 runes := 0
offset := 0 offset := 0
for offset < fileLen { for _, chunk := range cb.chunks {
end := offset + cb.chunkSize
if end > fileLen {
end = fileLen
}
idx := offset / cb.chunkSize
chunk, err := cb.chunkSync(idx)
if err != nil {
// Can't count past a broken chunk; stop here.
fmt.Printf("RuneIndexToByte: %v\n", err)
return offset + len(chunk)
}
for i := 0; i < len(chunk); i++ { for i := 0; i < len(chunk); i++ {
b := chunk[i] b := chunk[i]
// A UTF-8 rune starts at an ASCII byte (<0x80) or a multi-byte // A UTF-8 rune starts at an ASCII byte (<0x80) or a multi-byte
@ -186,91 +213,69 @@ func (cb *ChunkedBuffer) RuneIndexToByte(n int) int {
runes++ runes++
} }
} }
offset = end offset += len(chunk)
} }
return fileLen return int(cb.fileLen)
} }
// FullContent reconstructs the entire file content from loaded or re-read chunks. // FullContent reconstructs the entire content by concatenating the ordered
// If a dirty chunk is missing from memory, it returns an error to prevent data loss. // chunks. For in-range files every chunk is resident, so this is exact and
// never returns an error.
func (cb *ChunkedBuffer) FullContent() (string, error) { func (cb *ChunkedBuffer) FullContent() (string, error) {
if cb.fileLen == 0 && len(cb.chunks) == 0 {
return "", nil
}
// Iterate up to the larger of the fileLen-derived chunk count and the
// highest in-memory chunk index. Deriving the bound solely from fileLen
// would truncate in-memory chunks that an insert grew past the old bound.
numChunks := 0
if cb.fileLen > 0 {
numChunks = int((cb.fileLen + int64(cb.chunkSize) - 1) / int64(cb.chunkSize))
}
for i := range cb.chunks {
if i+1 > numChunks {
numChunks = i + 1
}
}
var buf bytes.Buffer var buf bytes.Buffer
for i := 0; i < numChunks; i++ { for _, chunk := range cb.chunks {
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) buf.Write(chunk)
} }
return buf.String(), nil return buf.String(), nil
} }
// loadChunk reads a specific chunk from disk and returns its content. // loadChunk reads a chunk from disk. Retained for compatibility; in-range
// It also stores the chunk in the 'chunks' map. // files load fully via SetContent and do not use this.
// Uses FS.ReadFileAt to read only the chunk range (not the entire file).
func (cb *ChunkedBuffer) loadChunk(idx int) ([]byte, error) { func (cb *ChunkedBuffer) loadChunk(idx int) ([]byte, error) {
start := idx * cb.chunkSize start := idx * cb.chunkSize
chunk, err := cb.FS.ReadFileAt(cb.filename, start, cb.chunkSize) chunk, err := cb.FS.ReadFileAt(cb.filename, start, cb.chunkSize)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read chunk %d: %w", idx, err) return nil, err
} }
cb.chunks[idx] = chunk // Store the loaded chunk for len(cb.chunks) <= idx {
cb.chunks = append(cb.chunks, nil)
}
cb.chunks[idx] = chunk
return chunk, nil return chunk, nil
} }
// LoadChunk explicitly loads a chunk and prefetch adjacent ones. // LoadChunk loads a chunk (no-op for fully-loaded in-range files).
func (cb *ChunkedBuffer) LoadChunk(idx int) { func (cb *ChunkedBuffer) LoadChunk(idx int) {
if _, ok := cb.chunks[idx]; !ok { if cb.fullyLoaded {
_, err := cb.loadChunk(idx) return
if err != nil {
fmt.Printf("Error loading chunk %d: %v\n", idx, err)
} }
if idx >= 0 && idx < len(cb.chunks) && cb.chunks[idx] != nil {
return
} }
// Prefetch adjacent chunks if _, err := cb.loadChunk(idx); err != nil {
cb.Prefetch(idx, 1) // Best effort; in-range files should never reach here.
}
cb.lastPrefetchedChunk = idx
} }
// IsChunkLoaded returns true if the chunk is already in memory. // IsChunkLoaded reports whether the chunk is resident.
func (cb *ChunkedBuffer) IsChunkLoaded(idx int) bool { func (cb *ChunkedBuffer) IsChunkLoaded(idx int) bool {
_, ok := cb.chunks[idx] return idx >= 0 && idx < len(cb.chunks) && cb.chunks[idx] != nil
return ok
} }
// IsChunkLoading returns true if the chunk is currently being loaded. // IsChunkLoading reports whether a chunk load is in flight (never, for
// in-range files).
func (cb *ChunkedBuffer) IsChunkLoading(idx int) bool { func (cb *ChunkedBuffer) IsChunkLoading(idx int) bool {
return cb.loadingChunks[idx] return cb.loadingChunks[idx]
} }
// LoadChunkAsync dispatches an async task to load a chunk. // LoadChunkAsync dispatches an async chunk load (no-op for in-range files,
// which are fully resident).
func (cb *ChunkedBuffer) LoadChunkAsync(idx int) { func (cb *ChunkedBuffer) LoadChunkAsync(idx int) {
if cb.workerPool != nil && !cb.loadingChunks[idx] { if cb.fullyLoaded {
return
}
if cb.workerPool != nil && !cb.loadingChunks[idx] && !cb.IsChunkLoaded(idx) {
cb.loadingChunks[idx] = true cb.loadingChunks[idx] = true
cb.workerPool.DispatchNonBlocking( cb.workerPool.DispatchNonBlocking(
pool.NewReadChunkTask(cb.filename, idx, cb.FS), pool.NewReadChunkTask(cb.filename, idx, cb.FS),
@ -278,211 +283,112 @@ func (cb *ChunkedBuffer) LoadChunkAsync(idx int) {
} }
} }
// SetWorkerPool sets the worker pool for async chunk loading. // SetWorkerPool sets the worker pool (retained for compatibility).
func (cb *ChunkedBuffer) SetWorkerPool(wp *pool.WorkerPool) { func (cb *ChunkedBuffer) SetWorkerPool(wp *pool.WorkerPool) {
cb.workerPool = wp cb.workerPool = wp
} }
// LastPrefetchedChunk returns the last chunk that was prefetched. // LastPrefetchedChunk returns the last prefetched chunk index.
// Used by EditorLayout to avoid redundant prefetches.
func (cb *ChunkedBuffer) LastPrefetchedChunk() int { func (cb *ChunkedBuffer) LastPrefetchedChunk() int {
return cb.lastPrefetchedChunk return cb.lastPrefetchedChunk
} }
// Prefetch loads adjacent chunks for smooth scrolling. // Prefetch is a no-op for in-range files (all chunks resident).
// 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) { 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 {
// 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),
)
} 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 cb.lastPrefetchedChunk = centerChunk
} }
// EvictFarChunks removes chunks that are too far from the cursor. // EvictFarChunks is intentionally a no-op for in-range files: all chunks stay
// radius is the number of chunks to keep around the cursor. // resident so byte->chunk mapping and FullContent remain exact. (Evicting
// Dirty chunks (modified since the last disk write) are NEVER evicted, // would reintroduce the stale-disk re-read hazard the fixed-slot model had.)
// even if they are far from the cursor, to prevent data loss.
func (cb *ChunkedBuffer) EvictFarChunks(cursorPos int, radius int) { func (cb *ChunkedBuffer) EvictFarChunks(cursorPos int, radius int) {
if cb.fileLen == 0 { // no-op
return }
}
cursorChunk := cursorPos / cb.chunkSize
numChunks := int((cb.fileLen + int64(cb.chunkSize) - 1) / int64(cb.chunkSize))
for i := range cb.chunks { // markDirtyChunk flags the buffer and a chunk as modified.
// NEVER evict dirty chunks — they contain unsaved modifications func (cb *ChunkedBuffer) markDirtyChunk(idx int) {
if cb.dirtyChunks[i] { cb.dirty = true
continue if idx >= 0 {
} cb.dirtyChunks[idx] = true
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. // Insert inserts text at byte position pos, splicing only the affected chunk.
func (cb *ChunkedBuffer) Insert(pos int, text string) { func (cb *ChunkedBuffer) Insert(pos int, text string) {
if len(text) == 0 { if len(text) == 0 {
return return
} }
// Ensure the chunk containing pos is loaded fileLen := int(cb.fileLen)
chunkIdx := pos / cb.chunkSize if pos > fileLen {
// Ensure we don't try to insert beyond the current fileLen if it's not a new file pos = fileLen // clamp to end
if pos > int(cb.fileLen) && cb.fileLen > 0 {
pos = int(cb.fileLen) // clamp insertion point to end of file
} }
if pos < 0 { if pos < 0 {
pos = 0 pos = 0
} }
idx, local := cb.chunkForPos(pos)
// Load chunk if it doesn't exist, or if pos is at the very beginning of a non-loaded chunk if idx < 0 {
if _, ok := cb.chunks[chunkIdx]; !ok { // Empty buffer: create the first chunk.
// If inserting at the start of a chunk, we need to load it. cb.chunks = append(cb.chunks, []byte(text))
// If inserting past the end of the file, we might create new chunks. cb.fileLen = int64(len(text))
// For now, assume loadChunk handles cases where pos is beyond current fileLen by reading up to fileLen. cb.markDirtyChunk(0)
_, 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 return
} }
} chunk := cb.chunks[idx]
newChunk := make([]byte, 0, len(chunk)+len(text))
chunk := cb.chunks[chunkIdx] newChunk = append(newChunk, chunk[:local]...)
offsetInChunk := pos - chunkIdx*cb.chunkSize newChunk = append(newChunk, text...)
newChunk = append(newChunk, chunk[local:]...)
// Ensure offsetInChunk is valid for the loaded chunk cb.chunks[idx] = newChunk
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
cb.fileLen += int64(len(text)) cb.fileLen += int64(len(text))
cb.markDirtyChunk(idx)
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. // Delete deletes n bytes starting at pos, splicing only the affected chunk(s).
func (cb *ChunkedBuffer) Delete(pos, n int) { func (cb *ChunkedBuffer) Delete(pos, n int) {
if n <= 0 { if n <= 0 {
return return
} }
// Clamp pos and n to valid range fileLen := int(cb.fileLen)
if pos < 0 { if pos < 0 {
pos = 0 pos = 0
} }
if pos >= int(cb.fileLen) { if pos >= fileLen {
return // Nothing to delete return
} }
if pos+n > int(cb.fileLen) { if pos+n > fileLen {
n = int(cb.fileLen) - pos n = 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. The deletion region is the
// absolute byte range [pos, pos+n); each chunk we visit removes its
// overlap with that range. (Using a shrinking "remaining" to derive the
// per-chunk end was wrong: it mis-computed the end for every chunk after
// the first, corrupting multi-chunk deletes.)
absEnd := pos + n absEnd := pos + n
remaining := n offset := 0
for i := startChunk; i <= endChunk && remaining > 0; i++ { for i := range cb.chunks {
if i < 0 {
continue
}
chunk := cb.chunks[i] chunk := cb.chunks[i]
if chunk == nil { chunkStart := offset
chunkEnd := offset + len(chunk)
offset = chunkEnd
if chunkEnd <= pos || chunkStart >= absEnd {
continue // no overlap with [pos, absEnd)
}
delStart := max(pos, chunkStart) - chunkStart
delEnd := min(absEnd, chunkEnd) - chunkStart
if delStart >= delEnd {
continue continue
} }
cb.chunks[i] = append(chunk[:delStart], chunk[delEnd:]...)
currentChunkStart := i * cb.chunkSize cb.markDirtyChunk(i)
currentChunkEnd := currentChunkStart + len(chunk)
// Overlap of [pos, absEnd) with this chunk's absolute span.
delStart := max(pos, currentChunkStart)
delEnd := min(absEnd, currentChunkEnd)
if delStart >= delEnd {
continue // Deletion range doesn't overlap this chunk
} }
localStart := delStart - currentChunkStart
localEnd := delEnd - currentChunkStart
// Perform deletion within the chunk
cb.chunks[i] = append(chunk[:localStart], chunk[localEnd:]...)
remaining -= delEnd - delStart
// Mark this chunk as dirty so it won't be evicted
cb.dirtyChunks[i] = true
}
// Update file length
cb.fileLen -= int64(n) cb.fileLen -= int64(n)
if cb.fileLen < 0 { if cb.fileLen < 0 {
cb.fileLen = 0 cb.fileLen = 0
} }
cb.dirty = true cb.dirty = true
} }
// VisibleByteRange returns the byte range [start, end) that is visible on the
// VisibleByteRange returns the byte range [start, end) of content // current editor viewport, plus the visual line at the top of the viewport.
// visible in the viewport, given the current scroll offset and viewport height. // This drives the renderer's virtual scrolling: only this range of text is
// laid out into GlyphLayout, keeping memory and layout cost bounded by the
// viewport, not the file. It uses actual (prefix-sum) chunk offsets so the
// range stays correct after length-changing edits.
func (cb *ChunkedBuffer) VisibleByteRange(scrollOffset ui.Dp, byteOffset int, viewportHeight ui.Dp, lineHeight ui.Dp, wordWrap bool, layout ui.GlyphLayout, visualIndex *types.VisualLineIndex) (start, end, startLine int) { func (cb *ChunkedBuffer) VisibleByteRange(scrollOffset ui.Dp, byteOffset int, viewportHeight ui.Dp, lineHeight ui.Dp, wordWrap bool, layout ui.GlyphLayout, visualIndex *types.VisualLineIndex) (start, end, startLine int) {
// If wrapping and we have visual line data, use it for precise calculation // If wrapping and we have visual line data, use it for precise calculation
if wordWrap && len(layout.VisualLineStarts) > 0 { if wordWrap && len(layout.VisualLineStarts) > 0 {
@ -493,10 +399,9 @@ func (cb *ChunkedBuffer) VisibleByteRange(scrollOffset ui.Dp, byteOffset int, vi
} }
start = layout.VisualLineStarts[visualLine] + byteOffset start = layout.VisualLineStarts[visualLine] + byteOffset
fmt.Printf("VisibleByteRange: visualLine = %d start = %d\n", visualLine, start)
// Calculate end: enough content to fill viewport + buffer // Calculate end: enough content to fill viewport + buffer
linesInViewport := int(viewportHeight / lineHeight) + 2 linesInViewport := int(viewportHeight/lineHeight) + 2
endLine := visualLine + linesInViewport endLine := visualLine + linesInViewport
if endLine >= len(layout.VisualLineStarts) { if endLine >= len(layout.VisualLineStarts) {
end = int(cb.fileLen) end = int(cb.fileLen)
@ -531,11 +436,6 @@ func (cb *ChunkedBuffer) VisibleByteRange(scrollOffset ui.Dp, byteOffset int, vi
// visibleByteRangeEstimate approximates the visible byte range using // visibleByteRangeEstimate approximates the visible byte range using
// heuristic estimates. Used when the line index is not yet available. // heuristic estimates. Used when the line index is not yet available.
func (cb *ChunkedBuffer) visibleByteRangeEstimate(scrollOffset ui.Dp, viewportHeight ui.Dp) (start, end int) { 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. // Use the editor's line height constant for consistency.
lineHeight := EditorLineHeight() lineHeight := EditorLineHeight()
@ -545,38 +445,40 @@ func (cb *ChunkedBuffer) visibleByteRangeEstimate(scrollOffset ui.Dp, viewportHe
// Clamp line numbers to reasonable bounds // Clamp line numbers to reasonable bounds
totalLinesEstimate := 0 totalLinesEstimate := 0
if cb.fileLen > 0 { if cb.fileLen > 0 {
totalLinesEstimate = int(cb.fileLen / 50) + 1 // Rough estimate: 50 bytes per line 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
} }
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 // 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 { if cb.LineIndex != nil {
// Use LineIndex if it exists
if startLine < len(cb.LineIndex.Offsets) { if startLine < len(cb.LineIndex.Offsets) {
start = int(cb.LineIndex.Offsets[startLine]) start = int(cb.LineIndex.Offsets[startLine])
} else { } else {
// If startLine is beyond index, estimate based on last known offset and average line length
lastKnownOffset := int64(0) lastKnownOffset := int64(0)
if len(cb.LineIndex.Offsets) > 0 { if len(cb.LineIndex.Offsets) > 0 {
lastKnownOffset = int64(cb.LineIndex.Offsets[len(cb.LineIndex.Offsets)-1]) lastKnownOffset = int64(cb.LineIndex.Offsets[len(cb.LineIndex.Offsets)-1])
} }
linesBeyondIndex := startLine - (len(cb.LineIndex.Offsets) -1) linesBeyondIndex := startLine - (len(cb.LineIndex.Offsets) - 1)
start = int(lastKnownOffset + int64(linesBeyondIndex) * 50) // Estimate start = int(lastKnownOffset + int64(linesBeyondIndex)*50) // Estimate
} }
if endLine < len(cb.LineIndex.Offsets) { if endLine < len(cb.LineIndex.Offsets) {
end = int(cb.LineIndex.Offsets[endLine]) end = int(cb.LineIndex.Offsets[endLine])
} else { } else {
// If endLine is beyond index, estimate
lastKnownOffset := int64(0) lastKnownOffset := int64(0)
if len(cb.LineIndex.Offsets) > 0 { if len(cb.LineIndex.Offsets) > 0 {
lastKnownOffset = int64(cb.LineIndex.Offsets[len(cb.LineIndex.Offsets)-1]) lastKnownOffset = int64(cb.LineIndex.Offsets[len(cb.LineIndex.Offsets)-1])
} }
linesBeyondIndex := endLine - (len(cb.LineIndex.Offsets) -1) linesBeyondIndex := endLine - (len(cb.LineIndex.Offsets) - 1)
end = int(lastKnownOffset + int64(linesBeyondIndex) * 50) // Estimate end = int(lastKnownOffset + int64(linesBeyondIndex)*50) // Estimate
} }
} else { } else {
// Rough byte estimation if no LineIndex // Rough byte estimation if no LineIndex
@ -586,9 +488,15 @@ func (cb *ChunkedBuffer) visibleByteRangeEstimate(scrollOffset ui.Dp, viewportHe
// Clamp to file bounds // Clamp to file bounds
if cb.fileLen > 0 { if cb.fileLen > 0 {
if start < 0 { start = 0 } if start < 0 {
if end > int(cb.fileLen) { end = int(cb.fileLen) } start = 0
if end <= start { end = start + cb.chunkSize } // Ensure at least one chunk's worth if range is invalid }
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 { } else {
start = 0 start = 0
end = 0 // Empty file end = 0 // Empty file
@ -600,79 +508,64 @@ func (cb *ChunkedBuffer) visibleByteRangeEstimate(scrollOffset ui.Dp, viewportHe
// visibleByteRangePrecise uses the LineIndex to find the exact byte range. // visibleByteRangePrecise uses the LineIndex to find the exact byte range.
func (cb *ChunkedBuffer) visibleByteRangePrecise(scrollOffset ui.Dp, viewportHeight ui.Dp) (start, end int) { func (cb *ChunkedBuffer) visibleByteRangePrecise(scrollOffset ui.Dp, viewportHeight ui.Dp) (start, end int) {
if cb.LineIndex == nil || len(cb.LineIndex.Offsets) == 0 { 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) return cb.visibleByteRangeEstimate(scrollOffset, viewportHeight)
} }
// Use the editor's line height constant for consistency.
lineHeight := EditorLineHeight() lineHeight := EditorLineHeight()
startLine := int(scrollOffset / lineHeight) startLine := int(scrollOffset / lineHeight)
endLine := int((scrollOffset + viewportHeight) / lineHeight) endLine := int((scrollOffset + viewportHeight) / lineHeight)
// Clamp line numbers to the available range in LineIndex
if startLine < 0 { if startLine < 0 {
startLine = 0 startLine = 0
} }
if startLine >= len(cb.LineIndex.Offsets) { if startLine >= len(cb.LineIndex.Offsets) {
startLine = len(cb.LineIndex.Offsets) - 1 // Last available line startLine = len(cb.LineIndex.Offsets) - 1
} }
if endLine < 0 {
if endLine < 0 { // Should not happen with positive viewportHeight
endLine = 0 endLine = 0
} }
if endLine >= len(cb.LineIndex.Offsets) { if endLine >= len(cb.LineIndex.Offsets) {
endLine = len(cb.LineIndex.Offsets) - 1 // Last available line endLine = len(cb.LineIndex.Offsets) - 1
} }
// Ensure endLine is at least startLine + 1, unless startLine is already the last line.
if startLine < len(cb.LineIndex.Offsets)-1 && endLine <= startLine { if startLine < len(cb.LineIndex.Offsets)-1 && endLine <= startLine {
endLine = startLine + 1 endLine = startLine + 1
} }
start = int(cb.LineIndex.Offsets[startLine]) 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) { if endLine+1 < len(cb.LineIndex.Offsets) {
end = int(cb.LineIndex.Offsets[endLine+1]) end = int(cb.LineIndex.Offsets[endLine+1])
} else { } else {
end = int(cb.fileLen) // Use file length as the end if it's the last line end = int(cb.fileLen)
} }
// Ensure range is within file bounds if start < 0 {
if start < 0 { start = 0 } start = 0
if end > int(cb.fileLen) { end = int(cb.fileLen) } }
if end > int(cb.fileLen) {
end = int(cb.fileLen)
}
if end <= start { 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) { if start < int(cb.fileLen) {
end = min(start+cb.chunkSize, int(cb.fileLen)) // At least one chunk or up to file end end = min(start+cb.chunkSize, int(cb.fileLen))
} else { } else {
end = start // If start is already at file end, range is empty end = start
} }
} }
return start, end return start, end
} }
// maybeMergeChunk is a placeholder for logic that consolidates chunks if they become too small // UpdateLineIndexAfterEdit updates the LineIndex offsets after an insert or
// or if edits cause fragmentation. This is complex and deferred. // delete. offsetShift is the number of bytes inserted (positive) or deleted
func (cb *ChunkedBuffer) maybeMergeChunk(chunkIdx int) { // (negative); editPos is the byte position where the edit occurred.
// 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) { func (cb *ChunkedBuffer) UpdateLineIndexAfterEdit(editPos int, offsetShift int) {
if cb.LineIndex == nil { if cb.LineIndex == nil {
return return
} }
// Find the first offset that needs updating using binary search. // Find the first offset that needs updating using binary search. All
// All offsets >= editPos need to be shifted by offsetShift. // offsets >= editPos are shifted by offsetShift.
idx := sort.Search(len(cb.LineIndex.Offsets), func(i int) bool { idx := sort.Search(len(cb.LineIndex.Offsets), func(i int) bool {
return int(cb.LineIndex.Offsets[i]) >= editPos return int(cb.LineIndex.Offsets[i]) >= editPos
}) })
@ -682,14 +575,13 @@ func (cb *ChunkedBuffer) UpdateLineIndexAfterEdit(editPos int, offsetShift int)
for i := idx; i < len(cb.LineIndex.Offsets); i++ { for i := idx; i < len(cb.LineIndex.Offsets); i++ {
cb.LineIndex.Offsets[i] += int32(offsetShift) cb.LineIndex.Offsets[i] += int32(offsetShift)
} }
// Update the file size stamp
cb.LineIndex.Size += int64(offsetShift) cb.LineIndex.Size += int64(offsetShift)
if cb.LineIndex.Size < 0 { if cb.LineIndex.Size < 0 {
cb.LineIndex.Size = 0 cb.LineIndex.Size = 0
} }
} }
// Helper function for max // max returns the larger of two ints.
func max(a, b int) int { func max(a, b int) int {
if a > b { if a > b {
return a return a
@ -697,12 +589,10 @@ func max(a, b int) int {
return b return b
} }
// Helper function for min // min returns the smaller of two ints.
func min(a, b int) int { func min(a, b int) int {
if a < b { if a < b {
return a return a
} }
return b return b
} }

View File

@ -1,176 +1,193 @@
package editor package editor
import ( import (
"bytes"
"strings"
"testing" "testing"
"time"
"pad/internal/io/pool/mock"
) )
// TestEvictDirtyChunk_LosesEdits verifies that editing a chunk and then // newTestBuffer builds a fully-loaded (in-range) buffer from content using the
// evicting it does NOT lose the modification. Dirty chunks are protected // on-open path (SetContent), so all chunks are resident.
// from eviction even when far from the cursor. func newTestBuffer(t *testing.T, content []byte) *ChunkedBuffer {
func TestEvictDirtyChunk_LosesEdits(t *testing.T) { t.Helper()
mockFS := mock.NewFileSystem() cb := NewChunkedBuffer("/test.txt", DefaultChunkSize, nil, "")
filename := "/test.txt" cb.SetContent(content)
return cb
}
// Create a 200KB file (3 chunks: 64KB, 64KB, 72KB) // TestSetContent_MultipleChunks verifies the whole-file load splits content
// into ordered chunks and Content reconstructs it exactly.
func TestSetContent_MultipleChunks(t *testing.T) {
content := make([]byte, 200*1024) content := make([]byte, 200*1024)
for i := range content { for i := range content {
content[i] = byte(i % 256) content[i] = byte(i % 251)
} }
mockFS.AddFile(filename, content, time.Now()) cb := newTestBuffer(t, content)
cb := NewChunkedBuffer(filename, DefaultChunkSize, mockFS, "") // 200KB / 64KB = 3 full chunks (64KB each) + 1 partial (8KB) = 4 chunks.
cb.SetFileSize(200 * 1024) if got := len(cb.chunks); got != 4 {
cb.LoadChunk(0) t.Fatalf("expected 4 chunks, got %d", got)
// 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]))
} }
if cb.FileLen() != int64(len(content)) {
// 2. Simulate scrolling to chunk 2, which would normally trigger eviction of chunk 0 t.Fatalf("FileLen=%d, want %d", cb.FileLen(), len(content))
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() full, err := cb.FullContent()
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("FullContent error: %v", err)
} }
if full != string(content) {
// The first len(insertText) bytes must be the inserted text t.Fatalf("FullContent mismatch after SetContent")
if full[:len(insertText)] != insertText { }
t.Errorf("expected first %d bytes to be %q, got %q", len(insertText), insertText, full[:len(insertText)]) // Spot-check a range that spans the chunk-0/chunk-1 boundary.
seg := cb.Content(64*1024-10, 64*1024+10)
if seg != string(content[64*1024-10:64*1024+10]) {
t.Fatalf("Content across chunk boundary mismatch: got %q", seg)
} }
} }
// TestEvictDirtyChunk_DeleteLosesEdits verifies that a deletion in a chunk // TestInsertShiftsLaterChunks is the core drift-fix test: an insert in chunk 0
// survives eviction. Dirty chunks are never evicted. // must shift every later byte, and Content must still return the correct byte
func TestEvictDirtyChunk_DeleteLosesEdits(t *testing.T) { // at the shifted offset (the old fixed-slot i*chunkSize model failed here).
mockFS := mock.NewFileSystem() func TestInsertShiftsLaterChunks(t *testing.T) {
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) content := make([]byte, 200*1024)
for i := range content { for i := range content {
content[i] = byte(i % 256) content[i] = byte(i % 251)
} }
mockFS.AddFile(filename, content, time.Now()) cb := newTestBuffer(t, content)
cb := NewChunkedBuffer(filename, DefaultChunkSize, mockFS, "") const insLen = 3
cb.SetFileSize(200 * 1024) cb.Insert(0, strings.Repeat("X", insLen))
cb.LoadChunk(0)
cb.LoadChunk(1)
cb.LoadChunk(2)
// No edits — all chunks are clean // A byte originally at position 100000 (chunk 1) is now at 100003.
// Evict chunk 0 (far from cursor at chunk 2) want := content[100000]
t.Logf("DEBUG: chunks before eviction: %v", cb.chunks) if got := cb.Content(100000+insLen, 100001+insLen); got != string(want) {
cb.EvictFarChunks(2*DefaultChunkSize, 1) t.Fatalf("byte at shifted offset 100003 = %q, want %q", got, string(want))
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)
} }
// The inserted prefix is present.
// But chunks 1 and 2 should remain if got := cb.Content(0, insLen); got != strings.Repeat("X", insLen) {
if _, ok := cb.chunks[1]; !ok { t.Fatalf("prefix = %q, want %q", got, strings.Repeat("X", insLen))
t.Fatal("expected chunk 1 to remain")
} }
if _, ok := cb.chunks[2]; !ok { // Full content is the insert + original.
t.Fatal("expected chunk 2 to remain") full, _ := cb.FullContent()
if full != strings.Repeat("X", insLen)+string(content) {
t.Fatalf("FullContent after insert mismatch (len=%d, want %d)", len(full), insLen+len(content))
} }
} }
// TestFullContent_AfterEvictDirtyChunk_ReReadsFromDisk verifies that when a dirty // TestDeleteShiftsLaterChunks verifies a deletion in an early chunk shifts
// chunk is somehow evicted (e.g., by a bug), FullContent re-reads the OLD disk // later bytes left and Content/FullContent stay exact.
// content — confirming the data loss scenario. This test documents the bug before func TestDeleteShiftsLaterChunks(t *testing.T) {
// it is fixed. content := make([]byte, 200*1024)
func TestFullContent_AfterEvictDirtyChunk_ReReadsFromDisk(t *testing.T) { for i := range content {
mockFS := mock.NewFileSystem() content[i] = byte(i % 251)
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'")
} }
cb := newTestBuffer(t, content)
// Manually evict the dirty chunk (simulating the bug) const delLen = 100
t.Logf("DEBUG: before delete, chunks=%v", cb.chunks) cb.Delete(0, delLen)
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 // A byte originally at 100000 is now at 99900.
full, err := cb.FullContent() want := content[100000]
if err == nil { if got := cb.Content(100000-delLen, 100001-delLen); got != string(want) {
t.Errorf("expected error due to missing dirty chunk, got nil, full content: %q", full) t.Fatalf("byte at shifted offset = %q, want %q", got, string(want))
} else { }
t.Logf("DEBUG: caught expected error: %v", err) full, _ := cb.FullContent()
if full != string(content[delLen:]) {
t.Fatalf("FullContent after delete mismatch (len=%d, want %d)", len(full), len(content)-delLen)
}
}
// TestDeleteSpanningChunks verifies a deletion that spans multiple chunk
// boundaries is exact.
func TestDeleteSpanningChunks(t *testing.T) {
content := make([]byte, 200*1024)
for i := range content {
content[i] = byte(i % 251)
}
cb := newTestBuffer(t, content)
// Delete 64KB+50 bytes starting at 30KB (spans chunks 0,1,2).
const start = 30 * 1024
const n = 64*1024 + 50
cb.Delete(start, n)
expected := make([]byte, 0, len(content)-n)
expected = append(expected, content[:start]...)
expected = append(expected, content[start+n:]...)
full, _ := cb.FullContent()
if !bytes.Equal([]byte(full), expected) {
t.Fatalf("FullContent after spanning delete mismatch (len=%d, want %d)", len(full), len(expected))
}
if cb.FileLen() != int64(len(expected)) {
t.Fatalf("FileLen=%d, want %d", cb.FileLen(), len(expected))
}
}
// TestRuneIndexToByteAfterEdit verifies the IME rune->byte bridge stays correct
// after a length-changing edit (ASCII + multibyte).
func TestRuneIndexToByteAfterEdit(t *testing.T) {
// 2 chunks of ASCII so the edit crosses into chunk arithmetic.
var b bytes.Buffer
for i := 0; i < 100*1024; i++ {
b.WriteByte(byte('a' + i%26))
}
cb := newTestBuffer(t, b.Bytes())
// Insert a multibyte rune ("é" = 2 bytes) at rune 0.
cb.Insert(0, "é")
// Rune 0 is now 'é' (bytes 0..1). Rune 1 is the original first 'a' at byte 2.
if got := cb.RuneIndexToByte(0); got != 0 {
t.Fatalf("RuneIndexToByte(0)=%d, want 0", got)
}
if got := cb.RuneIndexToByte(1); got != 2 {
t.Fatalf("RuneIndexToByte(1)=%d, want 2", got)
}
// New rune R (R>0) is the original rune R-1 (originally at byte R-1), now
// at byte (R-1)+2 = R+1 (é is 1 rune, 2 bytes). Check one deep in chunk 1
// (past the 64KB boundary) to confirm the shift survives chunk boundaries.
deep := 70000
if got := cb.RuneIndexToByte(deep); got != deep+1 {
t.Fatalf("RuneIndexToByte(%d)=%d, want %d", deep, got, deep+1)
}
}
// TestContent_BoundsAndEmpty guards edge cases in the rewritten Content.
func TestContent_BoundsAndEmpty(t *testing.T) {
cb := newTestBuffer(t, []byte("hello world"))
if got := cb.Content(0, 5); got != "hello" {
t.Fatalf("Content(0,5)=%q, want hello", got)
}
if got := cb.Content(6, 11); got != "world" {
t.Fatalf("Content(6,11)=%q, want world", got)
}
// Out-of-range end clamps to fileLen.
if got := cb.Content(0, 1000); got != "hello world" {
t.Fatalf("Content(0,1000)=%q, want full", got)
}
// start >= end is empty.
if got := cb.Content(5, 5); got != "" {
t.Fatalf("Content(5,5)=%q, want empty", got)
}
// Empty buffer.
empty := newTestBuffer(t, nil)
if got := empty.Content(0, 10); got != "" {
t.Fatalf("empty Content=%q, want empty", got)
}
if got, _ := empty.FullContent(); got != "" {
t.Fatalf("empty FullContent=%q, want empty", got)
}
}
// TestAppendAtEnd inserts at the very end of the buffer (pos == fileLen), which
// maps to the last chunk's end.
func TestAppendAtEnd(t *testing.T) {
cb := newTestBuffer(t, []byte("abcdef"))
cb.Insert(6, "XYZ")
full, _ := cb.FullContent()
if full != "abcdefXYZ" {
t.Fatalf("append at end = %q, want abcdefXYZ", full)
}
if cb.FileLen() != 9 {
t.Fatalf("FileLen=%d, want 9", cb.FileLen())
} }
} }

View File

@ -184,45 +184,16 @@ func TestLargeFileChunkBoundary(t *testing.T) {
t.Fatalf("Failed to read saved file from mockFS: %v", err) t.Fatalf("Failed to read saved file from mockFS: %v", err)
} }
// Build the expected content using ChunkedBuffer's exact design: // Build the expected content by applying the same three inserts to the
// Each chunk of size chunkSize acts as an independent buffer. // original content as a whole, in order. This is the shift-correct ground
// We divide the original content into chunks, apply the edits to the correct chunk, // truth: each insert at an absolute position pushes all later bytes right.
// and then concatenate them. // (The old fixed-slot model treated each chunk as an independent buffer and
chunks := make([][]byte, 4) // did NOT shift later chunks; that was the bug this design replaces.)
for i := 0; i < 4; i++ { expectedStr := string(initialContent)
start := i * chunkSize expectedStr = expectedStr[:65000] + "CHUNK1" + expectedStr[65000:] // insert 1 @65000
end := start + chunkSize expectedStr = expectedStr[:65535] + "BOUNDARY" + expectedStr[65535:] // insert 2 @65535
if end > fileSize { expectedStr = expectedStr[:262000] + "CHUNK3" + expectedStr[262000:] // insert 3 @262000
end = fileSize expected := []byte(expectedStr)
}
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) { if len(savedContent) != len(expected) {
t.Errorf("Saved file length %d != expected %d (too long by %d, too short by %d)", t.Errorf("Saved file length %d != expected %d (too long by %d, too short by %d)",

View File

@ -27,11 +27,8 @@ func newChunkedState(t *testing.T, content string, chunkSize int) *State {
filename := "/ime_range.txt" filename := "/ime_range.txt"
mockFS.AddFile(filename, []byte(content), time.Now()) mockFS.AddFile(filename, []byte(content), time.Now())
cb := NewChunkedBuffer(filename, chunkSize, mockFS, "") cb := NewChunkedBuffer(filename, chunkSize, mockFS, "")
cb.SetFileSize(int64(len(content))) // Full-load via the on-open path (all chunks resident), matching production.
// Preload all chunks so RuneIndexToByte reads from memory deterministically. cb.SetContent([]byte(content))
for i := 0; i*chunkSize < len(content); i++ {
cb.LoadChunk(i)
}
st := NewState() st := NewState()
TheState = st TheState = st
st.Editor.Filename = filename st.Editor.Filename = filename

View File

@ -291,9 +291,10 @@ func (l *Logic) handleWorkerResult(res pool.Result) {
if res.Success { if res.Success {
if content, ok := res.Data.([]byte); ok { if content, ok := res.Data.([]byte); ok {
if l.state.Editor.ChunkedBuffer != nil { if l.state.Editor.ChunkedBuffer != nil {
// Always populate the chunked buffer and ensure its size is set. // Full-load the in-range file: set the whole content (split into
// resident chunks). No lazy load, so no stale-disk re-read.
l.state.Editor.ChunkedBuffer.SetFileSize(int64(len(content))) l.state.Editor.ChunkedBuffer.SetFileSize(int64(len(content)))
l.state.Editor.ChunkedBuffer.Insert(0, string(content)) l.state.Editor.ChunkedBuffer.SetContent(content)
// Also update the Buffer field for backward compatibility and for tests checking it. // Also update the Buffer field for backward compatibility and for tests checking it.
l.state.Editor.Buffer = string(content) l.state.Editor.Buffer = string(content)
} else { } else {
@ -303,11 +304,15 @@ func (l *Logic) handleWorkerResult(res pool.Result) {
} }
} }
} else if res.TaskType == pool.TypeReadChunk { } else if res.TaskType == pool.TypeReadChunk {
// In-range files load fully via SetContent, so this is only a fallback.
if cb := l.state.Editor.ChunkedBuffer; cb != nil { if cb := l.state.Editor.ChunkedBuffer; cb != nil {
delete(cb.loadingChunks, res.ChunkIdx) // Use explicit ChunkIdx field delete(cb.loadingChunks, res.ChunkIdx) // Use explicit ChunkIdx field
if res.Success { if res.Success {
if chunk, ok := res.Data.([]byte); ok { if chunk, ok := res.Data.([]byte); ok {
for len(cb.chunks) <= res.ChunkIdx {
cb.chunks = append(cb.chunks, nil)
}
cb.chunks[res.ChunkIdx] = chunk cb.chunks[res.ChunkIdx] = chunk
log.Printf("Logic: Loaded chunk %d for %s (%d bytes)", res.ChunkIdx, res.FilePath, len(chunk)) log.Printf("Logic: Loaded chunk %d for %s (%d bytes)", res.ChunkIdx, res.FilePath, len(chunk))
} }
@ -319,14 +324,20 @@ func (l *Logic) handleWorkerResult(res pool.Result) {
log.Printf("Logic: TypeStatFile result success=%v", res.Success) log.Printf("Logic: TypeStatFile result success=%v", res.Success)
if res.Success { if res.Success {
if stat, ok := res.Data.(*pool.FileStat); ok { if stat, ok := res.Data.(*pool.FileStat); ok {
// Size guard: refuse to edit files above the limit. The browser can
// still list them; the editor shows a "too large to edit" notice.
if stat.Size > MaxEditableFileSize {
l.state.Editor.TooLarge = true
l.state.Editor.TooLargeSize = stat.Size
log.Printf("Logic: %s is %d bytes, exceeds the %d-byte edit limit", stat.Path, stat.Size, MaxEditableFileSize)
l.frameChan <- l.frameOf(l.state.layout(l.browserManager))
return
}
if l.state.Editor.ChunkedBuffer != nil { if l.state.Editor.ChunkedBuffer != nil {
l.state.Editor.ChunkedBuffer.SetFileSize(stat.Size) l.state.Editor.ChunkedBuffer.SetFileSize(stat.Size)
// Load the chunk containing the cursor (position 0 at open) // Full-load: read the whole file and dispatch a line index build.
l.state.Editor.ChunkedBuffer.LoadChunk(0) l.workerPool.Dispatch(pool.NewReadFileTask(stat.Path, l.mockFS))
// Prefetch adjacent chunks l.workerPool.Dispatch(pool.NewBuildLineIndexTask(stat.Path, l.mockFS))
l.state.Editor.ChunkedBuffer.Prefetch(0, 1)
// Dispatch line index build task
l.workerPool.Dispatch(pool.NewBuildLineIndexTask(l.state.Editor.Filename, l.mockFS))
} }
} }
} }

View File

@ -25,6 +25,20 @@ func EditorLineHeight() ui.Dp {
return ui.Dp(float32(EditorFontSize) * EditorLineHeightScale) return ui.Dp(float32(EditorFontSize) * EditorLineHeightScale)
} }
// formatSize renders a byte count as a human-readable size (e.g. "10.4 MB").
func formatSize(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for m := n / unit; m >= unit; m /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "kMG"[exp])
}
// Page identifies which page the app is showing. // Page identifies which page the app is showing.
type Page int type Page int
@ -63,6 +77,11 @@ type EditorState struct {
// It is set during layout and is what an EditEvent.Range indexes into. // It is set during layout and is what an EditEvent.Range indexes into.
IMEWindowText string IMEWindowText string
Filename string Filename string
// TooLarge is set when an opened file exceeds MaxEditableFileSize. The
// editor shows a "too large to edit" notice instead of content (the
// browser can still list the file).
TooLarge bool
TooLargeSize int64
fileVersion map[string]int fileVersion map[string]int
lastWriteVersion map[string]int lastWriteVersion map[string]int
saveTimer *time.Timer saveTimer *time.Timer
@ -128,6 +147,7 @@ type State struct {
FocusedElementID string // ID of the currently focused element FocusedElementID string // ID of the currently focused element
Elems []ui.Element Elems []ui.Element
lastEvictionTime time.Time // Throttles chunk eviction lastEvictionTime time.Time // Throttles chunk eviction
justOpenedAt time.Time // when the editor page was last opened; used to swallow the opening tap
// Browser state (directly embedded per architecture §8) // Browser state (directly embedded per architecture §8)
Browser browser.BrowserState // Embedded, not a pointer Browser browser.BrowserState // Embedded, not a pointer
// Editor state // Editor state
@ -208,6 +228,12 @@ func ToggleWordWrap(data any) {
// Clamped to [0, MaxScroll] so content doesn't scroll past its ends. // Clamped to [0, MaxScroll] so content doesn't scroll past its ends.
// Also evicts chunks far from the cursor to keep memory bounded. // Also evicts chunks far from the cursor to keep memory bounded.
func HandleScroll(data any) { func HandleScroll(data any) {
// Swallow scroll from the opening gesture: the tap that opened the file can
// leak a scroll delta into the editor before the line index is built (when
// MaxScroll is a large estimate), leaving the viewport past the content.
if time.Since(TheState.justOpenedAt) < 300*time.Millisecond {
return
}
delta := data.(int) // pixels delta := data.(int) // pixels
TheState.ScrollOffset += ui.ToDp(ui.Px(delta), TheState.scale) TheState.ScrollOffset += ui.ToDp(ui.Px(delta), TheState.scale)
if TheState.ScrollOffset < 0 { if TheState.ScrollOffset < 0 {
@ -266,9 +292,16 @@ func OpenFile(data any) {
TheState.Editor.Filename = filename TheState.Editor.Filename = filename
TheState.Editor.CursorPosition = 0 // Reset cursor to top TheState.Editor.CursorPosition = 0 // Reset cursor to top
TheState.Editor.TooLarge = false
TheState.Editor.TooLargeSize = 0
TheState.ScrollOffset = 0 // Reset editor scroll to top when opening a new file TheState.ScrollOffset = 0 // Reset editor scroll to top when opening a new file
TheState.page = EditorPage TheState.page = EditorPage
TheState.FocusedElementID = "editor_text" // Set focus to editor TheState.FocusedElementID = "editor_text" // Set focus to editor
// The tap that opened this file is delivered to the browser row, but its
// gesture can leak into the now-visible editor and move the cursor/scroll.
// Record the open time so the editor can swallow taps in a short window
// right after open (the opening tap, not a deliberate editor tap).
TheState.justOpenedAt = time.Now()
} }
// SetChunkedBuffer sets the chunked buffer for the current editor state. // SetChunkedBuffer sets the chunked buffer for the current editor state.
@ -320,6 +353,10 @@ func HandleCursorMove(delta int) {
// HandleKeyDown interprets keyboard events for navigation and editing. // HandleKeyDown interprets keyboard events for navigation and editing.
// Receives both key.Event (as key.Name) and key.EditEvent from the main loop. // Receives both key.Event (as key.Name) and key.EditEvent from the main loop.
func HandleKeyDown(data any) { func HandleKeyDown(data any) {
// A too-large file is not editable: ignore all key input.
if TheState.Editor.TooLarge {
return
}
log.Printf("HandleKeyDown: received data of type %T: %v", data, data) log.Printf("HandleKeyDown: received data of type %T: %v", data, data)
switch v := data.(type) { switch v := data.(type) {
case key.EditEvent: case key.EditEvent:
@ -636,6 +673,10 @@ func runeIndexToByteStr(s string, n int) int {
// is byte-based, so they are converted to byte offsets first. Must be called // is byte-based, so they are converted to byte offsets first. Must be called
// on the logic goroutine (owner). // on the logic goroutine (owner).
func HandleReplaceRange(startRune, endRune int, text string) { func HandleReplaceRange(startRune, endRune int, text string) {
// A too-large file is not editable: ignore IME commits.
if TheState.Editor.TooLarge {
return
}
if startRune > endRune { if startRune > endRune {
startRune, endRune = endRune, startRune startRune, endRune = endRune, startRune
} }
@ -819,6 +860,16 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
maxScroll = 0 maxScroll = 0
} }
TheState.MaxScroll = maxScroll TheState.MaxScroll = maxScroll
// Keep the viewport within [0, MaxScroll] even when MaxScroll just shrank
// (the line index finished building, or a shorter file was opened). Without
// this, a ScrollOffset set while MaxScroll was the large pre-index estimate
// would stay past the (now smaller) content and render a blank viewport.
if TheState.ScrollOffset < 0 {
TheState.ScrollOffset = 0
}
if TheState.ScrollOffset > maxScroll {
TheState.ScrollOffset = maxScroll
}
// Compute visible content for virtual scrolling. // Compute visible content for virtual scrolling.
var visibleContent string var visibleContent string
@ -827,7 +878,15 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
var start, end int var start, end int
cb := TheState.Editor.ChunkedBuffer cb := TheState.Editor.ChunkedBuffer
if cb != nil { if TheState.Editor.TooLarge {
// The file exceeds the edit limit: show a notice instead of content and
// do not edit (the browser can still list the file).
visibleContent = fmt.Sprintf(
"Too large to edit\n\n%s is %s, above the %s limit.\nIt is still listed in the browser.",
TheState.Editor.Filename, formatSize(TheState.Editor.TooLargeSize), formatSize(MaxEditableFileSize))
visibleCursorPos = 0
visibleScrollOffset = 0
} else if cb != nil {
viewportHeight := editorRegion.H viewportHeight := editorRegion.H
lineHeight := EditorLineHeight() lineHeight := EditorLineHeight()
if lh := TheState.Editor.GlyphLayout.LineHeight; lh > 0 { if lh := TheState.Editor.GlyphLayout.LineHeight; lh > 0 {
@ -844,9 +903,10 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
} }
} }
// Extract visible content from chunked buffer // Extract visible content from chunked buffer. Read the full visible
//visibleContent = cb.Content(start, end) // range [start, end) so a tall viewport is filled (a fixed 2000-byte
visibleContent = cb.Content(start, start+2000) // window could leave the lower rows blank).
visibleContent = cb.Content(start, end)
// Adjust cursor position to be relative to visibleContent // Adjust cursor position to be relative to visibleContent
visibleCursorPos = TheState.Editor.CursorPosition - start visibleCursorPos = TheState.Editor.CursorPosition - start
@ -886,13 +946,6 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
visibleScrollOffset = TheState.ScrollOffset visibleScrollOffset = TheState.ScrollOffset
} }
var s string
if len(visibleContent)>10 {
s = visibleContent[:10]
} else {
s = visibleContent
}
fmt.Printf("LAYOUT: visibleScrollOffset: %f visibleContent (%d) = %s...\n",visibleScrollOffset,len(visibleContent),s)
// Record the visible window for IME: the snippet is this window, so an // Record the visible window for IME: the snippet is this window, so an
// EditEvent.Range (relative to the window) is offset by IMEWindowStartByte // EditEvent.Range (relative to the window) is offset by IMEWindowStartByte
// to address the buffer. start is 0 for small (string) files, so the // to address the buffer. start is 0 for small (string) files, so the
@ -911,6 +964,13 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
{Gesture: ui.Scroll, Handler: HandleScroll}, {Gesture: ui.Scroll, Handler: HandleScroll},
{Gesture: ui.KeyDown, Handler: HandleKeyDown}, {Gesture: ui.KeyDown, Handler: HandleKeyDown},
{Gesture: ui.Tap, Handler: func(data any) { {Gesture: ui.Tap, Handler: func(data any) {
// Swallow the tap that opened the file. Its gesture belongs to the
// browser row we just left and must not position the cursor in the
// editor (a deliberate tap-to-position happens later, well past this
// window).
if time.Since(TheState.justOpenedAt) < 300*time.Millisecond {
return
}
if pt, ok := data.(ui.Point); ok { if pt, ok := data.(ui.Point); ok {
// Convert window-space tap coordinates to text-local coordinates. // Convert window-space tap coordinates to text-local coordinates.
// layout.X is relative to the text region left, and layout.Y is relative to the text region top. // layout.X is relative to the text region left, and layout.Y is relative to the text region top.