package editor import ( "bytes" "sort" "pad/internal/io/pool" "pad/internal/io/pool/types" "pad/internal/ui" ) const ( 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. // // 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 { filename string chunkSize int // target chunk size (e.g. 64 KB); actual chunks may vary fileLen int64 // total content length chunks [][]byte // ordered; chunks[i] is the i-th chunk dirty bool // true if buffer has been modified FS pool.FileSystem // filesystem for reads basePath string // base path for file resolution // line index is built asynchronously LineIndex *types.LineIndex // workerPool is retained for API compatibility; in-range files load fully // up front, so chunk loading no longer dispatches worker tasks. workerPool *pool.WorkerPool // lastPrefetchedChunk tracks the last chunk that was prefetched, so callers // can avoid redundant work. Kept for compatibility. lastPrefetchedChunk int // dirtyChunks tracks which individual chunks have been modified since they // were last persisted to disk. dirtyChunks map[int]bool // loadingChunks is retained for API compatibility; it is always empty for // in-range files (no lazy loading). 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. func NewChunkedBuffer(filename string, chunkSize int, fs pool.FileSystem, basePath string) *ChunkedBuffer { if chunkSize <= 0 { chunkSize = DefaultChunkSize } return &ChunkedBuffer{ filename: filename, chunkSize: chunkSize, fileLen: 0, chunks: nil, dirtyChunks: make(map[int]bool), loadingChunks: make(map[int]bool), FS: fs, basePath: basePath, } } // 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) { if !cb.dirty { cb.fileLen = length } } // FileLen returns the total content length. func (cb *ChunkedBuffer) FileLen() int64 { return cb.fileLen } // Filename returns the filename. func (cb *ChunkedBuffer) Filename() string { return cb.filename } // ChunkSize returns the target chunk size. func (cb *ChunkedBuffer) ChunkSize() int { return cb.chunkSize } // SetContent replaces the buffer content with data, split into ordered chunks // 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 { if cb.fileLen == 0 { return "" } if start < 0 { start = 0 } if end > int(cb.fileLen) { end = int(cb.fileLen) } if start >= end { return "" } var buf bytes.Buffer offset := 0 for _, chunk := range cb.chunks { chunkStart := offset chunkEnd := offset + len(chunk) offset = chunkEnd if chunkEnd <= start { continue } if chunkStart >= end { break } segStart := max(start, chunkStart) - chunkStart segEnd := min(end, chunkEnd) - chunkStart if segStart < segEnd { buf.Write(chunk[segStart:segEnd]) } } return buf.String() } // 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 // byte-based, so this bridges the two. It walks chunks by actual length and // stops as soon as the n-th rune is found. If n is at or past the end it // returns the content length. func (cb *ChunkedBuffer) RuneIndexToByte(n int) int { if n <= 0 { return 0 } if cb.fileLen == 0 { return 0 } runes := 0 offset := 0 for _, chunk := range cb.chunks { for i := 0; i < len(chunk); i++ { b := chunk[i] // A UTF-8 rune starts at an ASCII byte (<0x80) or a multi-byte // lead byte (>=0xC0); 0x80-0xBF are continuation bytes. if b < 0x80 || b >= 0xC0 { if runes == n { return offset + i } runes++ } } offset += len(chunk) } return int(cb.fileLen) } // FullContent reconstructs the entire content by concatenating the ordered // chunks. For in-range files every chunk is resident, so this is exact and // never returns an error. func (cb *ChunkedBuffer) FullContent() (string, error) { var buf bytes.Buffer for _, chunk := range cb.chunks { buf.Write(chunk) } return buf.String(), nil } // loadChunk reads a chunk from disk. Retained for compatibility; in-range // files load fully via SetContent and do not use this. 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, err } for len(cb.chunks) <= idx { cb.chunks = append(cb.chunks, nil) } cb.chunks[idx] = chunk return chunk, nil } // LoadChunk loads a chunk (no-op for fully-loaded in-range files). func (cb *ChunkedBuffer) LoadChunk(idx int) { if cb.fullyLoaded { return } if idx >= 0 && idx < len(cb.chunks) && cb.chunks[idx] != nil { return } if _, err := cb.loadChunk(idx); err != nil { // Best effort; in-range files should never reach here. } cb.lastPrefetchedChunk = idx } // IsChunkLoaded reports whether the chunk is resident. func (cb *ChunkedBuffer) IsChunkLoaded(idx int) bool { return idx >= 0 && idx < len(cb.chunks) && cb.chunks[idx] != nil } // IsChunkLoading reports whether a chunk load is in flight (never, for // in-range files). func (cb *ChunkedBuffer) IsChunkLoading(idx int) bool { return cb.loadingChunks[idx] } // LoadChunkAsync dispatches an async chunk load (no-op for in-range files, // which are fully resident). func (cb *ChunkedBuffer) LoadChunkAsync(idx int) { if cb.fullyLoaded { return } if cb.workerPool != nil && !cb.loadingChunks[idx] && !cb.IsChunkLoaded(idx) { cb.loadingChunks[idx] = true cb.workerPool.DispatchNonBlocking( pool.NewReadChunkTask(cb.filename, idx, cb.FS), ) } } // SetWorkerPool sets the worker pool (retained for compatibility). func (cb *ChunkedBuffer) SetWorkerPool(wp *pool.WorkerPool) { cb.workerPool = wp } // LastPrefetchedChunk returns the last prefetched chunk index. func (cb *ChunkedBuffer) LastPrefetchedChunk() int { return cb.lastPrefetchedChunk } // Prefetch is a no-op for in-range files (all chunks resident). func (cb *ChunkedBuffer) Prefetch(centerChunk int, radius int) { cb.lastPrefetchedChunk = centerChunk } // EvictFarChunks is intentionally a no-op for in-range files: all chunks stay // resident so byte->chunk mapping and FullContent remain exact. (Evicting // would reintroduce the stale-disk re-read hazard the fixed-slot model had.) func (cb *ChunkedBuffer) EvictFarChunks(cursorPos int, radius int) { // no-op } // markDirtyChunk flags the buffer and a chunk as modified. func (cb *ChunkedBuffer) markDirtyChunk(idx int) { cb.dirty = true if idx >= 0 { cb.dirtyChunks[idx] = true } } // Insert inserts text at byte position pos, splicing only the affected chunk. func (cb *ChunkedBuffer) Insert(pos int, text string) { if len(text) == 0 { return } fileLen := int(cb.fileLen) if pos > fileLen { pos = fileLen // clamp to end } if pos < 0 { pos = 0 } idx, local := cb.chunkForPos(pos) if idx < 0 { // Empty buffer: create the first chunk. cb.chunks = append(cb.chunks, []byte(text)) cb.fileLen = int64(len(text)) cb.markDirtyChunk(0) return } chunk := cb.chunks[idx] newChunk := make([]byte, 0, len(chunk)+len(text)) newChunk = append(newChunk, chunk[:local]...) newChunk = append(newChunk, text...) newChunk = append(newChunk, chunk[local:]...) cb.chunks[idx] = newChunk cb.fileLen += int64(len(text)) cb.markDirtyChunk(idx) } // Delete deletes n bytes starting at pos, splicing only the affected chunk(s). func (cb *ChunkedBuffer) Delete(pos, n int) { if n <= 0 { return } fileLen := int(cb.fileLen) if pos < 0 { pos = 0 } if pos >= fileLen { return } if pos+n > fileLen { n = fileLen - pos } absEnd := pos + n offset := 0 for i := range cb.chunks { chunk := cb.chunks[i] 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 } cb.chunks[i] = append(chunk[:delStart], chunk[delEnd:]...) cb.markDirtyChunk(i) } cb.fileLen -= int64(n) if cb.fileLen < 0 { cb.fileLen = 0 } cb.dirty = true } // VisibleByteRange returns the byte range [start, end) that is visible on the // current editor viewport, plus the visual line at the top of the viewport. // 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) { // If wrapping and we have visual line data, use it for precise calculation if wordWrap && len(layout.VisualLineStarts) > 0 { // Calculate which visual line should be at the top based on scroll offset visualLine := int(scrollOffset / lineHeight) if visualLine >= len(layout.VisualLineStarts) { visualLine = len(layout.VisualLineStarts) - 1 } start = layout.VisualLineStarts[visualLine] + byteOffset // Calculate end: enough content to fill viewport + buffer linesInViewport := int(viewportHeight/lineHeight) + 2 endLine := visualLine + linesInViewport if endLine >= len(layout.VisualLineStarts) { end = int(cb.fileLen) } else { end = layout.VisualLineStarts[endLine] } // Clamp to file bounds if end > int(cb.fileLen) { end = int(cb.fileLen) } if start >= end { end = start + 1000 // minimum buffer if end > int(cb.fileLen) { end = int(cb.fileLen) } } return start, end, visualLine } // Fallback to LineIndex (logical lines) or estimate if: // 1. Not word wrapping // 2. No visual index available if cb.LineIndex == nil { start, end = cb.visibleByteRangeEstimate(scrollOffset, viewportHeight) return start, end, 0 } start, end = cb.visibleByteRangePrecise(scrollOffset, viewportHeight) return start, end, 0 } // 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) { // 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 cb.LineIndex != nil { if startLine < len(cb.LineIndex.Offsets) { start = int(cb.LineIndex.Offsets[startLine]) } else { 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 { 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 { return cb.visibleByteRangeEstimate(scrollOffset, viewportHeight) } lineHeight := EditorLineHeight() startLine := int(scrollOffset / lineHeight) endLine := int((scrollOffset + viewportHeight) / lineHeight) if startLine < 0 { startLine = 0 } if startLine >= len(cb.LineIndex.Offsets) { startLine = len(cb.LineIndex.Offsets) - 1 } if endLine < 0 { endLine = 0 } if endLine >= len(cb.LineIndex.Offsets) { endLine = len(cb.LineIndex.Offsets) - 1 } if startLine < len(cb.LineIndex.Offsets)-1 && endLine <= startLine { endLine = startLine + 1 } start = int(cb.LineIndex.Offsets[startLine]) if endLine+1 < len(cb.LineIndex.Offsets) { end = int(cb.LineIndex.Offsets[endLine+1]) } else { end = int(cb.fileLen) } if start < 0 { start = 0 } if end > int(cb.fileLen) { end = int(cb.fileLen) } if end <= start { if start < int(cb.fileLen) { end = min(start+cb.chunkSize, int(cb.fileLen)) } else { end = start } } return start, end } // 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. 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 are shifted by offsetShift. idx := sort.Search(len(cb.LineIndex.Offsets), func(i int) bool { return int(cb.LineIndex.Offsets[i]) >= editPos }) if idx == 0 { idx = 1 } for i := idx; i < len(cb.LineIndex.Offsets); i++ { cb.LineIndex.Offsets[i] += int32(offsetShift) } cb.LineIndex.Size += int64(offsetShift) if cb.LineIndex.Size < 0 { cb.LineIndex.Size = 0 } } // max returns the larger of two ints. func max(a, b int) int { if a > b { return a } return b } // min returns the smaller of two ints. func min(a, b int) int { if a < b { return a } return b }