package editor import ( "bytes" "sort" "strings" "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, so memory scales // roughly linearly with file size and stays bounded (no leak). Files above // this are rejected with a "too large to edit" state (the browser can still // list them). // // On-device measurement (Android emulator, SwiftShader): a 10 MB file uses // ~150 MB PSS / ~230 MB RSS at steady state and stays flat under scroll // (previously the shaper was handed the whole file each frame, ballooning // to ~1.1 GB and OOM-killing the process). 50 MB extrapolates to a few // hundred MB, comfortable on a modern phone. 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 grow/shrink with edits; Insert splits any chunk that grows past // twice the target size (see Insert), so the per-edit copy cost stays bounded // by O(chunkSize) even under sustained typing at one spot. Shrunken (even // empty) chunks are left in place: the chunk count never grows with deletes, // all readers walk actual lengths, and removing chunks would be pure churn. 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. If the result grows past twice the target chunk size (sustained // typing at one spot, or a large paste), the chunk is split in half so that // chunks stay O(chunkSize) and every future edit remains a bounded // O(chunkSize) copy. The split cut is an arbitrary byte offset, like the // chunk boundaries created by SetContent: chunk boundaries may fall inside // multi-byte sequences, which is fine because every reader reassembles whole // windows from chunk bytes (windows are always line-aligned, i.e. on rune // boundaries). 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: chunk the new text directly so a large first paste // does not create one oversized chunk. cb.chunks = nil for start := 0; start < len(text); start += cb.chunkSize { end := start + cb.chunkSize if end > len(text) { end = len(text) } cb.chunks = append(cb.chunks, []byte(text[start:end])) } 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:]...) if len(newChunk) > 2*cb.chunkSize { // Split in half and insert the second half after idx. cut := len(newChunk) / 2 second := make([]byte, len(newChunk)-cut) copy(second, newChunk[cut:]) newChunk = newChunk[:cut] cb.chunks = append(cb.chunks, nil) copy(cb.chunks[idx+2:], cb.chunks[idx+1:]) // overlap-safe (memmove) cb.chunks[idx+1] = second } 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) { // The visible range is always derived from the real-line LineIndex (or a // heuristic estimate before the index is ready). The previously-shaped // GlyphLayout (layout.VisualLineStarts) only covers the visible window, NOT // the whole document. Using it to bound the range made the end fall back to // the entire file whenever the viewport's line count exceeded the window's // visual-line count, which forced the text shaper to lay out the whole // document and ballooned memory to the file size (the shaper's internal // line/glyph buffers grow to the largest layout ever shaped and are never // released). That is the root cause of the ~1GB "Unknown" memory on large // files. // // Word wrap does not require a separate path: each real line produces at // least one visual line, so shaping viewportHeight/lineHeight real lines // always yields at least as many visual lines as fit in the viewport. The // extra wrapped lines are simply clipped by the renderer. 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 } // UpdateLineIndexAfterInsert records the insertion of `text` at absolute // position `pos`, maintaining LineIndex incrementally: // - old line starts below pos are unchanged; // - an old line start exactly at pos stays at pos (the byte before it is // unchanged by the insertion); // - old line starts above pos shift right by len(text); // - each '\n' inside `text` creates a new line start immediately after it. // // Equivalent to rebuilding the index from the edited content, but in // O(lines affected) instead of O(file). func (cb *ChunkedBuffer) UpdateLineIndexAfterInsert(pos int, text string) { li := cb.LineIndex if li == nil { return } old := li.Offsets lower := sort.Search(len(old), func(i int) bool { return int(old[i]) >= pos }) atPos := lower < len(old) && int(old[lower]) == pos rest := lower if atPos { rest++ } shift := int32(len(text)) newOff := make([]int32, 0, len(old)+strings.Count(text, "\n")+1) newOff = append(newOff, old[:lower]...) if atPos { newOff = append(newOff, int32(pos)) } for i, b := range text { if b == '\n' { newOff = append(newOff, int32(pos+i+1)) } } for _, o := range old[rest:] { newOff = append(newOff, o+shift) } li.Offsets = newOff li.Size += int64(len(text)) } // UpdateLineIndexAfterDelete records the deletion of the absolute byte range // [start, end), maintaining LineIndex incrementally: // - old line starts below start are unchanged; // - old line starts inside [start, end) are removed; // - old line starts at or above end shift left by end-start, except the one // at exactly end, which would land on `start` and is valid only if a line // starts there in the new content; // - `start` is (re)inserted as a line start iff start==0 or it was a line // start in the pre-edit index (equivalently, the byte before it is '\n'; // bytes below start are untouched by the deletion). func (cb *ChunkedBuffer) UpdateLineIndexAfterDelete(start, end int) { li := cb.LineIndex if li == nil { return } old := li.Offsets shift := int32(end - start) lower := sort.Search(len(old), func(i int) bool { return int(old[i]) >= start }) atStart := lower < len(old) && int(old[lower]) == start upper := sort.Search(len(old), func(i int) bool { return int(old[i]) >= end }) newOff := make([]int32, 0, len(old)) newOff = append(newOff, old[:lower]...) if start == 0 || atStart { newOff = append(newOff, int32(start)) } for _, o := range old[upper:] { no := o - shift if int(no) == start { // The old line start at exactly `end` shifted onto `start`. A line // starts there in the new content iff one already existed there // (added above); in neither case do we keep this shifted entry. continue } newOff = append(newOff, no) } li.Offsets = newOff li.Size -= int64(end - start) if li.Size < 0 { li.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 }