Stop mapping stale-tail view lines to the wrong buffer line

Track how many leading viewLines entries the most recent refresh built
from the current buffer (freshViewLineCount) and bound the view-line→
buffer-line mapping on it. The entries past that count are the stale tail
refreshViewLinesIfNeeded leaves in place for flicker-avoidance; they
belong to a previous, longer render and must not be mapped. The old
in-range guard was insufficient: with wrapping a stale entry's buffer
index can still be in range of the shrunk buffer. Within the fresh range
every entry was just built from the current buffer, so its index is
guaranteed in range and the guard is no longer needed.

This is the §8 correctness fix the identity-based escape restore depends
on, since that read scans the view buffer while it is still loading.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-06-08 09:55:58 +02:00
parent 93286c6a93
commit e3214a34dd
2 changed files with 18 additions and 13 deletions

View file

@ -74,6 +74,16 @@ type View struct {
// true and viewLines to nil
viewLines []viewLine
// Number of leading entries in viewLines that correspond to the current
// buffer (v.lines), as built by the most recent refreshViewLinesIfNeeded.
// refreshViewLinesIfNeeded overwrites viewLines in place without truncating,
// so when a re-render is shorter than the previous one the tail keeps stale
// entries from that previous render (drawn deliberately, to avoid flicker
// while the new content loads). Those entries don't correspond to the
// current buffer, so readers that map a view line to a buffer line must
// ignore them; this is the count of the entries they may trust.
freshViewLineCount int
// If the last character written was a newline, we don't write it but
// instead set pendingNewline to true. If more text is written, we write the
// newline then. This is to avoid having an extra blank at the end of the view.
@ -212,6 +222,7 @@ type pos struct {
func (v *View) clearViewLines() {
v.tainted = true
v.viewLines = nil
v.freshViewLineCount = 0
v.clearHover()
}
@ -1476,6 +1487,7 @@ func (v *View) refreshViewLinesIfNeeded() {
}
v.firstDirtyLine = len(lines)
v.freshViewLineCount = lineIdx
v.tainted = false
}
@ -1716,19 +1728,15 @@ func (v *View) BufferLineForViewLine(y int) (int, bool) {
func (v *View) bufferLineForViewLine(y int) (int, bool) {
v.refreshViewLinesIfNeeded()
if y < 0 || y >= len(v.viewLines) {
// Bound on freshViewLineCount rather than len(v.viewLines): the entries past
// it are a stale tail retained for flicker-avoidance (see freshViewLineCount)
// and don't correspond to the current buffer. Within the fresh range every
// entry's linesY was just built from v.lines, so it is guaranteed in range.
if y < 0 || y >= v.freshViewLineCount {
return 0, false
}
// refreshViewLinesIfNeeded overwrites viewLines in place without truncating,
// so while a shorter re-render is loading, the tail of viewLines can still
// hold stale entries pointing past the (shrunk) v.lines. Guard against that.
linesY := v.viewLines[y].linesY
if linesY >= len(v.lines) {
return 0, false
}
return linesY, true
return v.viewLines[y].linesY, true
}
// indexFunc allows to split lines by words taking into account spaces

View file

@ -230,10 +230,7 @@ func TestBufferLineForViewLineStaleTail(t *testing.T) {
// buffer, so the mapping must fail. (On the buggy code it instead maps to
// buffer line 1, the stale entry's lingering index.)
_, ok = v.BufferLineForViewLine(4)
/* EXPECTED:
assert.False(t, ok)
ACTUAL: */
assert.True(t, ok)
}
func TestContainsColoredText(t *testing.T) {