detached 6f5dceb

feat(vt): add stable row identities

commit
6f5dceb2ee7c87a34ecaab340f8a3a1c3faf7d41
author
brice · · 2026-08-02 19:43 UTC
committer
brice · · 2026-08-03 17:54 UTC
parent
cc10813
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +392 -33
M pkg/vt/buffer.go +22 -8
@@ -8,6 +8,7 @@ import "github.com/bnema/vev/pkg/renderer"
88 type buffer struct {
99 frame renderer.Frame
1010 boundaries []LineBound
11+ rowIDs []RowID
1112 }
1213
1314 // LineBound describes a physical row's logical extent. End is exclusive: it is
@@ -20,15 +21,19 @@ type LineBound struct {
2021 }
2122
2223 func newBuffer(width, height int) *buffer {
23- return &buffer{frame: renderer.NewFrame(width, height), boundaries: make([]LineBound, height)}
24+ return &buffer{frame: renderer.NewFrame(width, height), boundaries: make([]LineBound, height), rowIDs: make([]RowID, height)}
2425 }
2526
2627 func bufferFromFrame(frame renderer.Frame) *buffer {
27- return &buffer{frame: frame, boundaries: make([]LineBound, frame.Height)}
28+ return &buffer{frame: frame, boundaries: make([]LineBound, frame.Height), rowIDs: make([]RowID, frame.Height)}
2829 }
2930
3031 func (b *buffer) clone() *buffer {
31- out := &buffer{frame: cloneFrame(b.frame), boundaries: append([]LineBound(nil), b.boundaries...)}
32+ out := &buffer{
33+ frame: cloneFrame(b.frame),
34+ boundaries: append([]LineBound(nil), b.boundaries...),
35+ rowIDs: append([]RowID(nil), b.rowIDs...),
36+ }
3237 return out
3338 }
3439
@@ -106,8 +111,10 @@ func (b *buffer) scrollUp(top, bottom, n int) {
106111 // them.
107112 b.hard(top - 1)
108113 copy(b.boundaries[top:bottom-n+1], b.boundaries[top+n:bottom+1])
114+ copy(b.rowIDs[top:bottom-n+1], b.rowIDs[top+n:bottom+1])
109115 for y := bottom - n + 1; y <= bottom; y++ {
110116 b.boundaries[y] = LineBound{}
117+ b.rowIDs[y] = 0
111118 }
112119 b.hard(bottom - n)
113120 }
@@ -117,8 +124,10 @@ func (b *buffer) scrollDown(top, bottom, n int) {
117124 // the last moved row no longer precedes the row below it.
118125 b.hard(top - 1)
119126 copy(b.boundaries[top+n:bottom+1], b.boundaries[top:bottom-n+1])
127+ copy(b.rowIDs[top+n:bottom+1], b.rowIDs[top:bottom-n+1])
120128 for y := top; y < top+n; y++ {
121129 b.boundaries[y] = LineBound{}
130+ b.rowIDs[y] = 0
122131 }
123132 b.hard(bottom)
124133 }
@@ -150,7 +159,7 @@ func (b *buffer) hasSoft() bool {
150159
151160 // resizeFixed keeps hard physical lines independent. It is the common shell
152161 // path and avoids constructing logical-line scratch state when nothing wraps.
153-func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound) {
162+func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound, []RowID) {
154163 anchor := 0
155164 if active != nil {
156165 anchor = active.row
@@ -158,10 +167,12 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([]
158167 shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0))
159168 evicted := make([][]renderer.Cell, 0, shift)
160169 evictedBounds := make([]LineBound, 0, shift)
170+ evictedIDs := make([]RowID, 0, shift)
161171 for y := range shift {
162172 evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...))
163173 // The evicted row keeps its source width, so its extent needs no clamping.
164174 evictedBounds = append(evictedBounds, b.boundaries[y])
175+ evictedIDs = append(evictedIDs, b.rowIDs[y])
165176 }
166177 // Fixed-line resizes can retain the boundary backing store. In particular,
167178 // keep its capacity through a short viewport so the common shrink/grow
@@ -172,7 +183,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([]
172183 } else {
173184 boundaries = boundaries[:height]
174185 }
175- next := buffer{frame: renderer.NewFrame(width, height), boundaries: boundaries}
186+ next := buffer{frame: renderer.NewFrame(width, height), boundaries: boundaries, rowIDs: make([]RowID, height)}
176187 copied := 0
177188 for y := range height {
178189 sy := y + shift
@@ -181,6 +192,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([]
181192 }
182193 copy(next.frame.Row(y), b.frame.Row(sy))
183194 next.boundaries[y] = b.boundaries[sy]
195+ next.rowIDs[y] = b.rowIDs[sy]
184196 next.boundaries[y].End = min(next.boundaries[y].End, width)
185197 repairFrameRow(next.frame, y)
186198 copied++
@@ -193,7 +205,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([]
193205 }
194206 }
195207 *b = next
196- return evicted, evictedBounds
208+ return evicted, evictedBounds, evictedIDs
197209 }
198210
199211 type reflowPoint struct {
@@ -234,7 +246,7 @@ func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint
234246 // the first maps both cursors and counts rows; the second writes only the
235247 // retained viewport (and the rows genuinely evicted to history). It never
236248 // materializes logical lines, per-cell position maps, or temporary output rows.
237-func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound) {
249+func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound, []RowID) {
238250 if !b.hasSoft() {
239251 return b.resizeFixed(width, height, active, saved)
240252 }
@@ -259,12 +271,14 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]ren
259271 var evicted [][]renderer.Cell
260272 var evictedCells []renderer.Cell
261273 var evictedBounds []LineBound
274+ var evictedIDs []RowID
262275 if shift > 0 {
263276 // History needs owned rows. One flat backing store replaces a temporary
264277 // allocation per evicted output row.
265278 evicted = make([][]renderer.Cell, shift)
266279 evictedCells = make([]renderer.Cell, shift*width)
267280 evictedBounds = make([]LineBound, shift)
281+ evictedIDs = make([]RowID, shift)
268282 for y := range evicted {
269283 evicted[y] = evictedCells[y*width : (y+1)*width]
270284 }
@@ -277,7 +291,7 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]ren
277291 }
278292 }
279293 *b = *next
280- return evicted, evictedBounds
294+ return evicted, evictedBounds, evictedIDs
281295 }
282296
283297 // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in
@@ -8,6 +8,7 @@ import "github.com/bnema/vev/pkg/renderer"
8 type buffer struct {8 type buffer struct {
9 frame renderer.Frame9 frame renderer.Frame
10 boundaries []LineBound10 boundaries []LineBound
11+ rowIDs []RowID
11 }12 }
12 13
13 // LineBound describes a physical row's logical extent. End is exclusive: it is14 // LineBound describes a physical row's logical extent. End is exclusive: it is
@@ -20,15 +21,19 @@ type LineBound struct {
20 }21 }
21 22
22 func newBuffer(width, height int) *buffer {23 func newBuffer(width, height int) *buffer {
23- return &buffer{frame: renderer.NewFrame(width, height), boundaries: make([]LineBound, height)}24+ return &buffer{frame: renderer.NewFrame(width, height), boundaries: make([]LineBound, height), rowIDs: make([]RowID, height)}
24 }25 }
25 26
26 func bufferFromFrame(frame renderer.Frame) *buffer {27 func bufferFromFrame(frame renderer.Frame) *buffer {
27- return &buffer{frame: frame, boundaries: make([]LineBound, frame.Height)}28+ return &buffer{frame: frame, boundaries: make([]LineBound, frame.Height), rowIDs: make([]RowID, frame.Height)}
28 }29 }
29 30
30 func (b *buffer) clone() *buffer {31 func (b *buffer) clone() *buffer {
31- out := &buffer{frame: cloneFrame(b.frame), boundaries: append([]LineBound(nil), b.boundaries...)}32+ out := &buffer{
33+ frame: cloneFrame(b.frame),
34+ boundaries: append([]LineBound(nil), b.boundaries...),
35+ rowIDs: append([]RowID(nil), b.rowIDs...),
36+ }
32 return out37 return out
33 }38 }
34 39
@@ -106,8 +111,10 @@ func (b *buffer) scrollUp(top, bottom, n int) {
106 // them.111 // them.
107 b.hard(top - 1)112 b.hard(top - 1)
108 copy(b.boundaries[top:bottom-n+1], b.boundaries[top+n:bottom+1])113 copy(b.boundaries[top:bottom-n+1], b.boundaries[top+n:bottom+1])
114+ copy(b.rowIDs[top:bottom-n+1], b.rowIDs[top+n:bottom+1])
109 for y := bottom - n + 1; y <= bottom; y++ {115 for y := bottom - n + 1; y <= bottom; y++ {
110 b.boundaries[y] = LineBound{}116 b.boundaries[y] = LineBound{}
117+ b.rowIDs[y] = 0
111 }118 }
112 b.hard(bottom - n)119 b.hard(bottom - n)
113 }120 }
@@ -117,8 +124,10 @@ func (b *buffer) scrollDown(top, bottom, n int) {
117 // the last moved row no longer precedes the row below it.124 // the last moved row no longer precedes the row below it.
118 b.hard(top - 1)125 b.hard(top - 1)
119 copy(b.boundaries[top+n:bottom+1], b.boundaries[top:bottom-n+1])126 copy(b.boundaries[top+n:bottom+1], b.boundaries[top:bottom-n+1])
127+ copy(b.rowIDs[top+n:bottom+1], b.rowIDs[top:bottom-n+1])
120 for y := top; y < top+n; y++ {128 for y := top; y < top+n; y++ {
121 b.boundaries[y] = LineBound{}129 b.boundaries[y] = LineBound{}
130+ b.rowIDs[y] = 0
122 }131 }
123 b.hard(bottom)132 b.hard(bottom)
124 }133 }
@@ -150,7 +159,7 @@ func (b *buffer) hasSoft() bool {
150 159
151 // resizeFixed keeps hard physical lines independent. It is the common shell160 // resizeFixed keeps hard physical lines independent. It is the common shell
152 // path and avoids constructing logical-line scratch state when nothing wraps.161 // path and avoids constructing logical-line scratch state when nothing wraps.
153-func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound) {162+func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound, []RowID) {
154 anchor := 0163 anchor := 0
155 if active != nil {164 if active != nil {
156 anchor = active.row165 anchor = active.row
@@ -158,10 +167,12 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([]
158 shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0))167 shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0))
159 evicted := make([][]renderer.Cell, 0, shift)168 evicted := make([][]renderer.Cell, 0, shift)
160 evictedBounds := make([]LineBound, 0, shift)169 evictedBounds := make([]LineBound, 0, shift)
170+ evictedIDs := make([]RowID, 0, shift)
161 for y := range shift {171 for y := range shift {
162 evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...))172 evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...))
163 // The evicted row keeps its source width, so its extent needs no clamping.173 // The evicted row keeps its source width, so its extent needs no clamping.
164 evictedBounds = append(evictedBounds, b.boundaries[y])174 evictedBounds = append(evictedBounds, b.boundaries[y])
175+ evictedIDs = append(evictedIDs, b.rowIDs[y])
165 }176 }
166 // Fixed-line resizes can retain the boundary backing store. In particular,177 // Fixed-line resizes can retain the boundary backing store. In particular,
167 // keep its capacity through a short viewport so the common shrink/grow178 // keep its capacity through a short viewport so the common shrink/grow
@@ -172,7 +183,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([]
172 } else {183 } else {
173 boundaries = boundaries[:height]184 boundaries = boundaries[:height]
174 }185 }
175- next := buffer{frame: renderer.NewFrame(width, height), boundaries: boundaries}186+ next := buffer{frame: renderer.NewFrame(width, height), boundaries: boundaries, rowIDs: make([]RowID, height)}
176 copied := 0187 copied := 0
177 for y := range height {188 for y := range height {
178 sy := y + shift189 sy := y + shift
@@ -181,6 +192,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([]
181 }192 }
182 copy(next.frame.Row(y), b.frame.Row(sy))193 copy(next.frame.Row(y), b.frame.Row(sy))
183 next.boundaries[y] = b.boundaries[sy]194 next.boundaries[y] = b.boundaries[sy]
195+ next.rowIDs[y] = b.rowIDs[sy]
184 next.boundaries[y].End = min(next.boundaries[y].End, width)196 next.boundaries[y].End = min(next.boundaries[y].End, width)
185 repairFrameRow(next.frame, y)197 repairFrameRow(next.frame, y)
186 copied++198 copied++
@@ -193,7 +205,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([]
193 }205 }
194 }206 }
195 *b = next207 *b = next
196- return evicted, evictedBounds208+ return evicted, evictedBounds, evictedIDs
197 }209 }
198 210
199 type reflowPoint struct {211 type reflowPoint struct {
@@ -234,7 +246,7 @@ func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint
234 // the first maps both cursors and counts rows; the second writes only the246 // the first maps both cursors and counts rows; the second writes only the
235 // retained viewport (and the rows genuinely evicted to history). It never247 // retained viewport (and the rows genuinely evicted to history). It never
236 // materializes logical lines, per-cell position maps, or temporary output rows.248 // materializes logical lines, per-cell position maps, or temporary output rows.
237-func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound) {249+func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound, []RowID) {
238 if !b.hasSoft() {250 if !b.hasSoft() {
239 return b.resizeFixed(width, height, active, saved)251 return b.resizeFixed(width, height, active, saved)
240 }252 }
@@ -259,12 +271,14 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]ren
259 var evicted [][]renderer.Cell271 var evicted [][]renderer.Cell
260 var evictedCells []renderer.Cell272 var evictedCells []renderer.Cell
261 var evictedBounds []LineBound273 var evictedBounds []LineBound
274+ var evictedIDs []RowID
262 if shift > 0 {275 if shift > 0 {
263 // History needs owned rows. One flat backing store replaces a temporary276 // History needs owned rows. One flat backing store replaces a temporary
264 // allocation per evicted output row.277 // allocation per evicted output row.
265 evicted = make([][]renderer.Cell, shift)278 evicted = make([][]renderer.Cell, shift)
266 evictedCells = make([]renderer.Cell, shift*width)279 evictedCells = make([]renderer.Cell, shift*width)
267 evictedBounds = make([]LineBound, shift)280 evictedBounds = make([]LineBound, shift)
281+ evictedIDs = make([]RowID, shift)
268 for y := range evicted {282 for y := range evicted {
269 evicted[y] = evictedCells[y*width : (y+1)*width]283 evicted[y] = evictedCells[y*width : (y+1)*width]
270 }284 }
@@ -277,7 +291,7 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]ren
277 }291 }
278 }292 }
279 *b = *next293 *b = *next
280- return evicted, evictedBounds294+ return evicted, evictedBounds, evictedIDs
281 }295 }
282 296
283 // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in297 // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in
M pkg/vt/history.go +142 -10
@@ -28,8 +28,10 @@ type History struct {
2828 chunks []*HistoryChunk
2929 tail [][]renderer.Cell
3030 tailBounds []LineBound
31+ tailIDs []RowID
3132 rows int
3233 cells int
34+ nextRowID RowID
3335 }
3436
3537 // HistoryChunk is an immutable group of history rows. Its identity is stable
@@ -38,6 +40,7 @@ type History struct {
3840 type HistoryChunk struct {
3941 rows [][]renderer.Cell
4042 bounds []LineBound
43+ rowIDs []RowID
4144 }
4245
4346 // HistoryView is an immutable snapshot of history. Row returns a copy so the
@@ -55,6 +58,7 @@ type HistorySnapshotView struct {
5558 chunks []*HistoryChunk
5659 tail [][]renderer.Cell
5760 tailBounds []LineBound
61+ tailIDs []RowID
5862 rows int
5963 cells int
6064 }
@@ -73,7 +77,7 @@ func NewHistory(config HistoryConfig) *History {
7377 if maxCells <= 0 {
7478 maxCells = defaultHistoryMaxCells(config.MaxRows)
7579 }
76- return &History{maxRows: config.MaxRows, maxCells: maxCells, chunkRows: chunkRows}
80+ return &History{maxRows: config.MaxRows, maxCells: maxCells, chunkRows: chunkRows, nextRowID: 1}
7781 }
7882
7983 func defaultHistoryMaxCells(maxRows int) int {
@@ -83,21 +87,32 @@ func defaultHistoryMaxCells(maxRows int) int {
8387 return maxRows * 160
8488 }
8589
86-// Append records a copy of row along with its logical extent. Once a chunk is
87-// full it is sealed forever. Rows wider than the total cell capacity are
88-// rejected without mutation.
89-func (h *History) Append(row []renderer.Cell, bound LineBound) error {
90+// Append records a copy of row along with its logical extent and optional row
91+// identity. Calls without an identity receive a history-owned nonzero ID.
92+// Once a chunk is full it is sealed forever. Rows wider than the total cell
93+// capacity are rejected without mutation.
94+func (h *History) Append(row []renderer.Cell, bound LineBound, ids ...RowID) error {
9095 if h == nil || h.maxRows == 0 {
9196 return nil
9297 }
9398 if len(row) > h.maxCells {
9499 return ErrHistoryRowTooWide
95100 }
101+ id := RowID(0)
102+ if len(ids) > 0 {
103+ id = ids[0]
104+ }
105+ if id == 0 || h.hasRowID(id) {
106+ id = h.allocateRowID()
107+ } else if id >= h.nextRowID {
108+ h.nextRowID = id + 1
109+ }
96110 // Make room before adding so Cells cannot overflow even when the default
97111 // capacity is MaxInt.
98112 h.evictFor(len(row))
99113 h.tail = append(h.tail, append([]renderer.Cell(nil), row...))
100114 h.tailBounds = append(h.tailBounds, clampBound(bound, len(row)))
115+ h.tailIDs = append(h.tailIDs, id)
101116 h.rows++
102117 h.cells += len(row)
103118 if len(h.tail) == h.chunkRows {
@@ -106,6 +121,54 @@ func (h *History) Append(row []renderer.Cell, bound LineBound) error {
106121 return nil
107122 }
108123
124+func (h *History) allocateRowID() RowID {
125+ if h.nextRowID == 0 {
126+ h.nextRowID = 1
127+ }
128+ for h.hasRowID(h.nextRowID) {
129+ if h.nextRowID == ^RowID(0) {
130+ panic("vt: history row ID space exhausted")
131+ }
132+ h.nextRowID++
133+ }
134+ id := h.nextRowID
135+ if h.nextRowID == ^RowID(0) {
136+ panic("vt: history row ID space exhausted")
137+ }
138+ h.nextRowID++
139+ return id
140+}
141+
142+func (h *History) assignChunkIDs(chunk *HistoryChunk) {
143+ if chunk == nil {
144+ return
145+ }
146+ chunk.rowIDs = growRowIDs(chunk.rowIDs, len(chunk.rows))
147+ for i := range chunk.rows {
148+ if chunk.rowIDs[i] == 0 || h.hasRowID(chunk.rowIDs[i]) {
149+ chunk.rowIDs[i] = h.allocateRowID()
150+ } else if chunk.rowIDs[i] >= h.nextRowID {
151+ h.nextRowID = chunk.rowIDs[i] + 1
152+ }
153+ }
154+}
155+
156+func (h *History) hasRowID(id RowID) bool {
157+ for _, chunk := range h.chunks {
158+ for _, candidate := range chunk.rowIDs {
159+ if candidate == id {
160+ return true
161+ }
162+ }
163+ }
164+ for _, candidate := range h.tailIDs {
165+ if candidate == id {
166+ return true
167+ }
168+ }
169+ return false
170+}
171+
109172 // clampBound keeps End inside the row it describes so a persisted bound can be
110173 // validated without consulting its row again.
111174 func clampBound(b LineBound, cells int) LineBound {
@@ -124,26 +187,44 @@ func growBounds(bounds []LineBound, rows int) []LineBound {
124187 return out
125188 }
126189
190+func growRowIDs(ids []RowID, rows int) []RowID {
191+ if len(ids) == rows {
192+ return ids
193+ }
194+ out := make([]RowID, rows)
195+ copy(out, ids)
196+ return out
197+}
198+
127199 func (h *History) sealTail() {
128200 if len(h.tail) == 0 {
129201 return
130202 }
131- h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail, bounds: growBounds(h.tailBounds, len(h.tail))})
203+ h.chunks = append(h.chunks, &HistoryChunk{
204+ rows: h.tail,
205+ bounds: growBounds(h.tailBounds, len(h.tail)),
206+ rowIDs: h.tailIDs,
207+ })
132208 h.tail = nil
133209 h.tailBounds = nil
210+ h.tailIDs = nil
134211 }
135212
136213 // normalizeTail seals complete chunks so the mutable tail remains shorter than
137214 // chunkRows. Restored snapshots may have been written with a different chunk
138215 // size than the current history configuration.
139216 func (h *History) normalizeTail() {
217+ h.tailIDs = growRowIDs(h.tailIDs, len(h.tail))
140218 for h.chunkRows > 0 && len(h.tail) >= h.chunkRows {
219+ bounds := growBounds(h.tailBounds, len(h.tail))
141220 h.chunks = append(h.chunks, &HistoryChunk{
142221 rows: h.tail[:h.chunkRows],
143- bounds: growBounds(h.tailBounds, len(h.tail))[:h.chunkRows],
222+ bounds: bounds[:h.chunkRows],
223+ rowIDs: h.tailIDs[:h.chunkRows],
144224 })
145225 h.tail = h.tail[h.chunkRows:]
146- h.tailBounds = growBounds(h.tailBounds, len(h.tail)+h.chunkRows)[h.chunkRows:]
226+ h.tailBounds = bounds[h.chunkRows:]
227+ h.tailIDs = h.tailIDs[h.chunkRows:]
147228 }
148229 }
149230
@@ -172,7 +253,13 @@ func (h *History) evictUntil(rowCount, cellCount int) {
172253 } else {
173254 // Preserve cell storage while replacing only the chunk wrapper: a
174255 // retained view may still refer to the original immutable chunk.
175- h.chunks[0] = &HistoryChunk{rows: chunk.rows[1:], bounds: growBounds(chunk.bounds, len(chunk.rows))[1:]}
256+ bounds := growBounds(chunk.bounds, len(chunk.rows))
257+ rowIDs := growRowIDs(chunk.rowIDs, len(chunk.rows))
258+ h.chunks[0] = &HistoryChunk{
259+ rows: chunk.rows[1:],
260+ bounds: bounds[1:],
261+ rowIDs: rowIDs[1:],
262+ }
176263 }
177264 continue
178265 }
@@ -185,6 +272,7 @@ func (h *History) evictUntil(rowCount, cellCount int) {
185272 h.tail[0] = nil
186273 h.tail = h.tail[1:]
187274 h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:]
275+ h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)+1)[1:]
188276 }
189277 }
190278
@@ -208,6 +296,7 @@ func (h *History) View() HistoryView {
208296 chunks = append(chunks, &HistoryChunk{
209297 rows: cloneHistoryRows(h.tail),
210298 bounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),
299+ rowIDs: growRowIDs(h.tailIDs, len(h.tail)),
211300 })
212301 }
213302 return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells}
@@ -223,6 +312,7 @@ func (h *History) SnapshotView() HistorySnapshotView {
223312 chunks: append([]*HistoryChunk(nil), h.chunks...),
224313 tail: cloneHistoryRows(h.tail),
225314 tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),
315+ tailIDs: growRowIDs(h.tailIDs, len(h.tail)),
226316 rows: h.rows,
227317 cells: h.cells,
228318 }
@@ -316,6 +406,48 @@ func (v HistoryView) BorrowedRow(i int) []renderer.Cell {
316406 return nil
317407 }
318408
409+// RowID returns the identity of the row at i, or zero when i is out of range.
410+func (v HistoryView) RowID(i int) RowID {
411+ if i < 0 {
412+ return 0
413+ }
414+ for _, chunk := range v.chunks {
415+ if i < len(chunk.rows) {
416+ if i < len(chunk.rowIDs) {
417+ return chunk.rowIDs[i]
418+ }
419+ return 0
420+ }
421+ i -= len(chunk.rows)
422+ }
423+ return 0
424+}
425+
426+// FindRowID returns the oldest-first index of id, or -1 when id is absent.
427+func (v HistoryView) FindRowID(id RowID) int {
428+ if id == 0 {
429+ return -1
430+ }
431+ index := 0
432+ for _, chunk := range v.chunks {
433+ for row := range chunk.rows {
434+ if row < len(chunk.rowIDs) && chunk.rowIDs[row] == id {
435+ return index
436+ }
437+ index++
438+ }
439+ }
440+ return -1
441+}
442+
443+// RowID returns the identity of the row within an immutable chunk.
444+func (c *HistoryChunk) RowID(i int) RowID {
445+ if c == nil || i < 0 || i >= len(c.rowIDs) {
446+ return 0
447+ }
448+ return c.rowIDs[i]
449+}
450+
319451 // Bound returns the logical extent of the row at i, or the zero value when i is
320452 // out of range. A zero value describes a hard row whose End is not meaningful.
321453 func (v HistoryView) Bound(i int) LineBound {
@@ -352,7 +484,7 @@ func (v HistorySnapshotView) Tail() HistoryView {
352484 return HistoryView{}
353485 }
354486 return HistoryView{
355- chunks: []*HistoryChunk{{rows: v.tail, bounds: growBounds(v.tailBounds, len(v.tail))}},
487+ chunks: []*HistoryChunk{{rows: v.tail, bounds: growBounds(v.tailBounds, len(v.tail)), rowIDs: growRowIDs(v.tailIDs, len(v.tail))}},
356488 rows: len(v.tail),
357489 cells: historyRowsCells(v.tail),
358490 }
@@ -28,8 +28,10 @@ type History struct {
28 chunks []*HistoryChunk28 chunks []*HistoryChunk
29 tail [][]renderer.Cell29 tail [][]renderer.Cell
30 tailBounds []LineBound30 tailBounds []LineBound
31+ tailIDs []RowID
31 rows int32 rows int
32 cells int33 cells int
34+ nextRowID RowID
33 }35 }
34 36
35 // HistoryChunk is an immutable group of history rows. Its identity is stable37 // HistoryChunk is an immutable group of history rows. Its identity is stable
@@ -38,6 +40,7 @@ type History struct {
38 type HistoryChunk struct {40 type HistoryChunk struct {
39 rows [][]renderer.Cell41 rows [][]renderer.Cell
40 bounds []LineBound42 bounds []LineBound
43+ rowIDs []RowID
41 }44 }
42 45
43 // HistoryView is an immutable snapshot of history. Row returns a copy so the46 // HistoryView is an immutable snapshot of history. Row returns a copy so the
@@ -55,6 +58,7 @@ type HistorySnapshotView struct {
55 chunks []*HistoryChunk58 chunks []*HistoryChunk
56 tail [][]renderer.Cell59 tail [][]renderer.Cell
57 tailBounds []LineBound60 tailBounds []LineBound
61+ tailIDs []RowID
58 rows int62 rows int
59 cells int63 cells int
60 }64 }
@@ -73,7 +77,7 @@ func NewHistory(config HistoryConfig) *History {
73 if maxCells <= 0 {77 if maxCells <= 0 {
74 maxCells = defaultHistoryMaxCells(config.MaxRows)78 maxCells = defaultHistoryMaxCells(config.MaxRows)
75 }79 }
76- return &History{maxRows: config.MaxRows, maxCells: maxCells, chunkRows: chunkRows}80+ return &History{maxRows: config.MaxRows, maxCells: maxCells, chunkRows: chunkRows, nextRowID: 1}
77 }81 }
78 82
79 func defaultHistoryMaxCells(maxRows int) int {83 func defaultHistoryMaxCells(maxRows int) int {
@@ -83,21 +87,32 @@ func defaultHistoryMaxCells(maxRows int) int {
83 return maxRows * 16087 return maxRows * 160
84 }88 }
85 89
86-// Append records a copy of row along with its logical extent. Once a chunk is90+// Append records a copy of row along with its logical extent and optional row
87-// full it is sealed forever. Rows wider than the total cell capacity are91+// identity. Calls without an identity receive a history-owned nonzero ID.
88-// rejected without mutation.92+// Once a chunk is full it is sealed forever. Rows wider than the total cell
89-func (h *History) Append(row []renderer.Cell, bound LineBound) error {93+// capacity are rejected without mutation.
94+func (h *History) Append(row []renderer.Cell, bound LineBound, ids ...RowID) error {
90 if h == nil || h.maxRows == 0 {95 if h == nil || h.maxRows == 0 {
91 return nil96 return nil
92 }97 }
93 if len(row) > h.maxCells {98 if len(row) > h.maxCells {
94 return ErrHistoryRowTooWide99 return ErrHistoryRowTooWide
95 }100 }
101+ id := RowID(0)
102+ if len(ids) > 0 {
103+ id = ids[0]
104+ }
105+ if id == 0 || h.hasRowID(id) {
106+ id = h.allocateRowID()
107+ } else if id >= h.nextRowID {
108+ h.nextRowID = id + 1
109+ }
96 // Make room before adding so Cells cannot overflow even when the default110 // Make room before adding so Cells cannot overflow even when the default
97 // capacity is MaxInt.111 // capacity is MaxInt.
98 h.evictFor(len(row))112 h.evictFor(len(row))
99 h.tail = append(h.tail, append([]renderer.Cell(nil), row...))113 h.tail = append(h.tail, append([]renderer.Cell(nil), row...))
100 h.tailBounds = append(h.tailBounds, clampBound(bound, len(row)))114 h.tailBounds = append(h.tailBounds, clampBound(bound, len(row)))
115+ h.tailIDs = append(h.tailIDs, id)
101 h.rows++116 h.rows++
102 h.cells += len(row)117 h.cells += len(row)
103 if len(h.tail) == h.chunkRows {118 if len(h.tail) == h.chunkRows {
@@ -106,6 +121,54 @@ func (h *History) Append(row []renderer.Cell, bound LineBound) error {
106 return nil121 return nil
107 }122 }
108 123
124+func (h *History) allocateRowID() RowID {
125+ if h.nextRowID == 0 {
126+ h.nextRowID = 1
127+ }
128+ for h.hasRowID(h.nextRowID) {
129+ if h.nextRowID == ^RowID(0) {
130+ panic("vt: history row ID space exhausted")
131+ }
132+ h.nextRowID++
133+ }
134+ id := h.nextRowID
135+ if h.nextRowID == ^RowID(0) {
136+ panic("vt: history row ID space exhausted")
137+ }
138+ h.nextRowID++
139+ return id
140+}
141+
142+func (h *History) assignChunkIDs(chunk *HistoryChunk) {
143+ if chunk == nil {
144+ return
145+ }
146+ chunk.rowIDs = growRowIDs(chunk.rowIDs, len(chunk.rows))
147+ for i := range chunk.rows {
148+ if chunk.rowIDs[i] == 0 || h.hasRowID(chunk.rowIDs[i]) {
149+ chunk.rowIDs[i] = h.allocateRowID()
150+ } else if chunk.rowIDs[i] >= h.nextRowID {
151+ h.nextRowID = chunk.rowIDs[i] + 1
152+ }
153+ }
154+}
155+
156+func (h *History) hasRowID(id RowID) bool {
157+ for _, chunk := range h.chunks {
158+ for _, candidate := range chunk.rowIDs {
159+ if candidate == id {
160+ return true
161+ }
162+ }
163+ }
164+ for _, candidate := range h.tailIDs {
165+ if candidate == id {
166+ return true
167+ }
168+ }
169+ return false
170+}
171+
109 // clampBound keeps End inside the row it describes so a persisted bound can be172 // clampBound keeps End inside the row it describes so a persisted bound can be
110 // validated without consulting its row again.173 // validated without consulting its row again.
111 func clampBound(b LineBound, cells int) LineBound {174 func clampBound(b LineBound, cells int) LineBound {
@@ -124,26 +187,44 @@ func growBounds(bounds []LineBound, rows int) []LineBound {
124 return out187 return out
125 }188 }
126 189
190+func growRowIDs(ids []RowID, rows int) []RowID {
191+ if len(ids) == rows {
192+ return ids
193+ }
194+ out := make([]RowID, rows)
195+ copy(out, ids)
196+ return out
197+}
198+
127 func (h *History) sealTail() {199 func (h *History) sealTail() {
128 if len(h.tail) == 0 {200 if len(h.tail) == 0 {
129 return201 return
130 }202 }
131- h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail, bounds: growBounds(h.tailBounds, len(h.tail))})203+ h.chunks = append(h.chunks, &HistoryChunk{
204+ rows: h.tail,
205+ bounds: growBounds(h.tailBounds, len(h.tail)),
206+ rowIDs: h.tailIDs,
207+ })
132 h.tail = nil208 h.tail = nil
133 h.tailBounds = nil209 h.tailBounds = nil
210+ h.tailIDs = nil
134 }211 }
135 212
136 // normalizeTail seals complete chunks so the mutable tail remains shorter than213 // normalizeTail seals complete chunks so the mutable tail remains shorter than
137 // chunkRows. Restored snapshots may have been written with a different chunk214 // chunkRows. Restored snapshots may have been written with a different chunk
138 // size than the current history configuration.215 // size than the current history configuration.
139 func (h *History) normalizeTail() {216 func (h *History) normalizeTail() {
217+ h.tailIDs = growRowIDs(h.tailIDs, len(h.tail))
140 for h.chunkRows > 0 && len(h.tail) >= h.chunkRows {218 for h.chunkRows > 0 && len(h.tail) >= h.chunkRows {
219+ bounds := growBounds(h.tailBounds, len(h.tail))
141 h.chunks = append(h.chunks, &HistoryChunk{220 h.chunks = append(h.chunks, &HistoryChunk{
142 rows: h.tail[:h.chunkRows],221 rows: h.tail[:h.chunkRows],
143- bounds: growBounds(h.tailBounds, len(h.tail))[:h.chunkRows],222+ bounds: bounds[:h.chunkRows],
223+ rowIDs: h.tailIDs[:h.chunkRows],
144 })224 })
145 h.tail = h.tail[h.chunkRows:]225 h.tail = h.tail[h.chunkRows:]
146- h.tailBounds = growBounds(h.tailBounds, len(h.tail)+h.chunkRows)[h.chunkRows:]226+ h.tailBounds = bounds[h.chunkRows:]
227+ h.tailIDs = h.tailIDs[h.chunkRows:]
147 }228 }
148 }229 }
149 230
@@ -172,7 +253,13 @@ func (h *History) evictUntil(rowCount, cellCount int) {
172 } else {253 } else {
173 // Preserve cell storage while replacing only the chunk wrapper: a254 // Preserve cell storage while replacing only the chunk wrapper: a
174 // retained view may still refer to the original immutable chunk.255 // retained view may still refer to the original immutable chunk.
175- h.chunks[0] = &HistoryChunk{rows: chunk.rows[1:], bounds: growBounds(chunk.bounds, len(chunk.rows))[1:]}256+ bounds := growBounds(chunk.bounds, len(chunk.rows))
257+ rowIDs := growRowIDs(chunk.rowIDs, len(chunk.rows))
258+ h.chunks[0] = &HistoryChunk{
259+ rows: chunk.rows[1:],
260+ bounds: bounds[1:],
261+ rowIDs: rowIDs[1:],
262+ }
176 }263 }
177 continue264 continue
178 }265 }
@@ -185,6 +272,7 @@ func (h *History) evictUntil(rowCount, cellCount int) {
185 h.tail[0] = nil272 h.tail[0] = nil
186 h.tail = h.tail[1:]273 h.tail = h.tail[1:]
187 h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:]274 h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:]
275+ h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)+1)[1:]
188 }276 }
189 }277 }
190 278
@@ -208,6 +296,7 @@ func (h *History) View() HistoryView {
208 chunks = append(chunks, &HistoryChunk{296 chunks = append(chunks, &HistoryChunk{
209 rows: cloneHistoryRows(h.tail),297 rows: cloneHistoryRows(h.tail),
210 bounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),298 bounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),
299+ rowIDs: growRowIDs(h.tailIDs, len(h.tail)),
211 })300 })
212 }301 }
213 return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells}302 return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells}
@@ -223,6 +312,7 @@ func (h *History) SnapshotView() HistorySnapshotView {
223 chunks: append([]*HistoryChunk(nil), h.chunks...),312 chunks: append([]*HistoryChunk(nil), h.chunks...),
224 tail: cloneHistoryRows(h.tail),313 tail: cloneHistoryRows(h.tail),
225 tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),314 tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),
315+ tailIDs: growRowIDs(h.tailIDs, len(h.tail)),
226 rows: h.rows,316 rows: h.rows,
227 cells: h.cells,317 cells: h.cells,
228 }318 }
@@ -316,6 +406,48 @@ func (v HistoryView) BorrowedRow(i int) []renderer.Cell {
316 return nil406 return nil
317 }407 }
318 408
409+// RowID returns the identity of the row at i, or zero when i is out of range.
410+func (v HistoryView) RowID(i int) RowID {
411+ if i < 0 {
412+ return 0
413+ }
414+ for _, chunk := range v.chunks {
415+ if i < len(chunk.rows) {
416+ if i < len(chunk.rowIDs) {
417+ return chunk.rowIDs[i]
418+ }
419+ return 0
420+ }
421+ i -= len(chunk.rows)
422+ }
423+ return 0
424+}
425+
426+// FindRowID returns the oldest-first index of id, or -1 when id is absent.
427+func (v HistoryView) FindRowID(id RowID) int {
428+ if id == 0 {
429+ return -1
430+ }
431+ index := 0
432+ for _, chunk := range v.chunks {
433+ for row := range chunk.rows {
434+ if row < len(chunk.rowIDs) && chunk.rowIDs[row] == id {
435+ return index
436+ }
437+ index++
438+ }
439+ }
440+ return -1
441+}
442+
443+// RowID returns the identity of the row within an immutable chunk.
444+func (c *HistoryChunk) RowID(i int) RowID {
445+ if c == nil || i < 0 || i >= len(c.rowIDs) {
446+ return 0
447+ }
448+ return c.rowIDs[i]
449+}
450+
319 // Bound returns the logical extent of the row at i, or the zero value when i is451 // Bound returns the logical extent of the row at i, or the zero value when i is
320 // out of range. A zero value describes a hard row whose End is not meaningful.452 // out of range. A zero value describes a hard row whose End is not meaningful.
321 func (v HistoryView) Bound(i int) LineBound {453 func (v HistoryView) Bound(i int) LineBound {
@@ -352,7 +484,7 @@ func (v HistorySnapshotView) Tail() HistoryView {
352 return HistoryView{}484 return HistoryView{}
353 }485 }
354 return HistoryView{486 return HistoryView{
355- chunks: []*HistoryChunk{{rows: v.tail, bounds: growBounds(v.tailBounds, len(v.tail))}},487+ chunks: []*HistoryChunk{{rows: v.tail, bounds: growBounds(v.tailBounds, len(v.tail)), rowIDs: growRowIDs(v.tailIDs, len(v.tail))}},
356 rows: len(v.tail),488 rows: len(v.tail),
357 cells: historyRowsCells(v.tail),489 cells: historyRowsCells(v.tail),
358 }490 }
M pkg/vt/history_codec.go +6 -1
@@ -200,6 +200,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
200200 }
201201 stats := historyDecodeStats{chunks: uint64(chunkCount)}
202202 var chunks []*HistoryChunk
203+ var nextRowID RowID = 1
203204 if populate {
204205 chunks = make([]*HistoryChunk, 0, chunkCount)
205206 }
@@ -213,9 +214,11 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
213214
214215 var rows [][]renderer.Cell
215216 var bounds []LineBound
217+ var rowIDs []RowID
216218 if populate {
217219 rows = make([][]renderer.Cell, 0, rowCount)
218220 bounds = make([]LineBound, 0, rowCount)
221+ rowIDs = make([]RowID, 0, rowCount)
219222 }
220223 for range rowCount {
221224 cellCount, ok := p.uint32()
@@ -248,10 +251,12 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
248251 if populate {
249252 rows = append(rows, row)
250253 bounds = append(bounds, bound)
254+ rowIDs = append(rowIDs, nextRowID)
255+ nextRowID++
251256 }
252257 }
253258 if populate {
254- chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds})
259+ chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds, rowIDs: rowIDs})
255260 }
256261 }
257262 if len(p.data) != 0 {
@@ -200,6 +200,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
200 }200 }
201 stats := historyDecodeStats{chunks: uint64(chunkCount)}201 stats := historyDecodeStats{chunks: uint64(chunkCount)}
202 var chunks []*HistoryChunk202 var chunks []*HistoryChunk
203+ var nextRowID RowID = 1
203 if populate {204 if populate {
204 chunks = make([]*HistoryChunk, 0, chunkCount)205 chunks = make([]*HistoryChunk, 0, chunkCount)
205 }206 }
@@ -213,9 +214,11 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
213 214
214 var rows [][]renderer.Cell215 var rows [][]renderer.Cell
215 var bounds []LineBound216 var bounds []LineBound
217+ var rowIDs []RowID
216 if populate {218 if populate {
217 rows = make([][]renderer.Cell, 0, rowCount)219 rows = make([][]renderer.Cell, 0, rowCount)
218 bounds = make([]LineBound, 0, rowCount)220 bounds = make([]LineBound, 0, rowCount)
221+ rowIDs = make([]RowID, 0, rowCount)
219 }222 }
220 for range rowCount {223 for range rowCount {
221 cellCount, ok := p.uint32()224 cellCount, ok := p.uint32()
@@ -248,10 +251,12 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
248 if populate {251 if populate {
249 rows = append(rows, row)252 rows = append(rows, row)
250 bounds = append(bounds, bound)253 bounds = append(bounds, bound)
254+ rowIDs = append(rowIDs, nextRowID)
255+ nextRowID++
251 }256 }
252 }257 }
253 if populate {258 if populate {
254- chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds})259+ chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds, rowIDs: rowIDs})
255 }260 }
256 }261 }
257 if len(p.data) != 0 {262 if len(p.data) != 0 {
A pkg/vt/row_id.go +49 -0
@@ -0,0 +1,49 @@
1+package vt
2+
3+// RowID identifies one physical terminal row for the lifetime of a Screen.
4+// Row IDs are never zero and are never reused by their owning Screen.
5+type RowID uint64
6+
7+func (s *Screen) nextRowIDValue() RowID {
8+ if s.nextRowID == ^RowID(0) {
9+ panic("vt: row ID space exhausted")
10+ }
11+ s.nextRowID++
12+ return s.nextRowID
13+}
14+
15+func (s *Screen) newBuffer(width, height int) *buffer {
16+ b := newBuffer(width, height)
17+ for i := range b.rowIDs {
18+ b.rowIDs[i] = s.nextRowIDValue()
19+ }
20+ return b
21+}
22+
23+func (s *Screen) fillMissingRowIDs(b *buffer) {
24+ if b == nil {
25+ return
26+ }
27+ for i := range b.rowIDs {
28+ if b.rowIDs[i] == 0 {
29+ b.rowIDs[i] = s.nextRowIDValue()
30+ }
31+ }
32+}
33+
34+// RowIDs returns an owned copy of the active live grid's row identities.
35+func (s *Screen) RowIDs() []RowID {
36+ if s == nil || s.buffer == nil {
37+ return nil
38+ }
39+ return append([]RowID(nil), s.buffer.rowIDs...)
40+}
41+
42+// RowID returns the identity of active live row y, or zero when y is out of
43+// range.
44+func (s *Screen) RowID(y int) RowID {
45+ if s == nil || s.buffer == nil || y < 0 || y >= len(s.buffer.rowIDs) {
46+ return 0
47+ }
48+ return s.buffer.rowIDs[y]
49+}
@@ -0,0 +1,49 @@
1+package vt
2+
3+// RowID identifies one physical terminal row for the lifetime of a Screen.
4+// Row IDs are never zero and are never reused by their owning Screen.
5+type RowID uint64
6+
7+func (s *Screen) nextRowIDValue() RowID {
8+ if s.nextRowID == ^RowID(0) {
9+ panic("vt: row ID space exhausted")
10+ }
11+ s.nextRowID++
12+ return s.nextRowID
13+}
14+
15+func (s *Screen) newBuffer(width, height int) *buffer {
16+ b := newBuffer(width, height)
17+ for i := range b.rowIDs {
18+ b.rowIDs[i] = s.nextRowIDValue()
19+ }
20+ return b
21+}
22+
23+func (s *Screen) fillMissingRowIDs(b *buffer) {
24+ if b == nil {
25+ return
26+ }
27+ for i := range b.rowIDs {
28+ if b.rowIDs[i] == 0 {
29+ b.rowIDs[i] = s.nextRowIDValue()
30+ }
31+ }
32+}
33+
34+// RowIDs returns an owned copy of the active live grid's row identities.
35+func (s *Screen) RowIDs() []RowID {
36+ if s == nil || s.buffer == nil {
37+ return nil
38+ }
39+ return append([]RowID(nil), s.buffer.rowIDs...)
40+}
41+
42+// RowID returns the identity of active live row y, or zero when y is out of
43+// range.
44+func (s *Screen) RowID(y int) RowID {
45+ if s == nil || s.buffer == nil || y < 0 || y >= len(s.buffer.rowIDs) {
46+ return 0
47+ }
48+ return s.buffer.rowIDs[y]
49+}
A pkg/vt/row_id_test.go +134 -0
@@ -0,0 +1,134 @@
1+package vt
2+
3+import "testing"
4+
5+func TestScreenRowIDsRotateWithRowsAndHistory(t *testing.T) {
6+ s := NewScreenWithHistory(4, 3, HistoryConfig{MaxRows: 8, ChunkRows: 2})
7+ initial := s.RowIDs()
8+ assertUniqueNonzeroRowIDs(t, initial)
9+
10+ s.Write([]byte("AAAA\r\nBBBB\r\nCCCC\r\n"))
11+
12+ live := s.RowIDs()
13+ if got, want := live, []RowID{initial[1], initial[2], live[2]}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
14+ t.Fatalf("live row IDs = %v, want rows moved from %v with a fresh blank row", got, initial)
15+ }
16+ if live[2] == initial[0] || live[2] == initial[1] || live[2] == initial[2] {
17+ t.Fatalf("new blank row reused a row ID: live=%v initial=%v", live, initial)
18+ }
19+ assertUniqueNonzeroRowIDs(t, live)
20+
21+ view := s.History().SealAndView()
22+ if got, want := view.RowID(0), initial[0]; got != want {
23+ t.Fatalf("evicted row ID = %d, want %d", got, want)
24+ }
25+ if got := view.FindRowID(initial[0]); got != 0 {
26+ t.Fatalf("FindRowID(%d) = %d, want 0", initial[0], got)
27+ }
28+}
29+
30+func TestScreenRowIDsDoNotAppendInteriorOrAlternateScrollHistory(t *testing.T) {
31+ s := NewScreenWithHistory(4, 4, HistoryConfig{MaxRows: 8})
32+ s.Write([]byte("AAAA\r\nBBBB\r\nCCCC\r\nDDDD"))
33+ s.Write([]byte("\x1b[2;4r\x1b[4;1H\n"))
34+ if got := s.History().Len(); got != 0 {
35+ t.Fatalf("interior scroll history rows = %d, want 0", got)
36+ }
37+ assertUniqueNonzeroRowIDs(t, s.RowIDs())
38+ primary := s.RowIDs()
39+
40+ s.Write([]byte("\x1b[?1049h1111\r\n2222\r\n3333\r\n4444\r\n"))
41+ if got := s.History().Len(); got != 0 {
42+ t.Fatalf("alternate scroll history rows = %d, want 0", got)
43+ }
44+ s.Write([]byte("\x1b[?1049l"))
45+ for i, id := range primary {
46+ if got := s.RowID(i); got != id {
47+ t.Fatalf("restored primary row %d ID = %d, want %d", i, got, id)
48+ }
49+ }
50+}
51+
52+func TestScreenRowIDsRefreshOnClearResetAndAlternateClone(t *testing.T) {
53+ s := NewScreen(4, 3)
54+ before := s.RowIDs()
55+ s.Write([]byte("AAAA\x1b[2J"))
56+ cleared := s.RowIDs()
57+ assertFreshRowIDs(t, before, cleared)
58+
59+ s.Write([]byte("\x1bc"))
60+ reset := s.RowIDs()
61+ assertFreshRowIDs(t, cleared, reset)
62+
63+ s.Write([]byte("BBBB\x1b[?1049h"))
64+ alternate := s.RowIDs()
65+ assertFreshRowIDs(t, reset, alternate)
66+ s.Write([]byte("CCCC\x1b[?1049l"))
67+ for i, id := range reset {
68+ if got := s.RowID(i); got != id {
69+ t.Fatalf("cloned primary row %d ID = %d, want %d", i, got, id)
70+ }
71+ }
72+}
73+
74+func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) {
75+ h := NewHistory(HistoryConfig{MaxRows: 3, ChunkRows: 2})
76+ for i, text := range []string{"one", "two"} {
77+ if err := h.Append(historyRow(text), LineBound{End: len(text)}, RowID(i+1)); err != nil {
78+ t.Fatal(err)
79+ }
80+ }
81+ before := h.SealAndView()
82+ if got, want := before.RowID(0), RowID(1); got != want {
83+ t.Fatalf("sealed row ID = %d, want %d", got, want)
84+ }
85+
86+ if err := h.Append(historyRow("three"), LineBound{End: 5}, RowID(3)); err != nil {
87+ t.Fatal(err)
88+ }
89+ if got := before.FindRowID(2); got != 1 {
90+ t.Fatalf("retained view FindRowID(2) = %d, want 1", got)
91+ }
92+ after := h.SealAndView()
93+ if got := after.FindRowID(3); got != 2 {
94+ t.Fatalf("new view FindRowID(3) = %d, want 2", got)
95+ }
96+
97+ if err := h.Append(historyRow("four"), LineBound{End: 4}, RowID(4)); err != nil {
98+ t.Fatal(err)
99+ }
100+ if got := before.FindRowID(1); got != 0 {
101+ t.Fatalf("eviction mutated retained view FindRowID(1) = %d, want 0", got)
102+ }
103+ if got := h.View().FindRowID(1); got != -1 {
104+ t.Fatalf("evicted current FindRowID(1) = %d, want -1", got)
105+ }
106+}
107+
108+func assertUniqueNonzeroRowIDs(t *testing.T, ids []RowID) {
109+ t.Helper()
110+ seen := make(map[RowID]struct{}, len(ids))
111+ for _, id := range ids {
112+ if id == 0 {
113+ t.Fatal("row ID is zero")
114+ }
115+ if _, ok := seen[id]; ok {
116+ t.Fatalf("duplicate row ID %d in %v", id, ids)
117+ }
118+ seen[id] = struct{}{}
119+ }
120+}
121+
122+func assertFreshRowIDs(t *testing.T, before, after []RowID) {
123+ t.Helper()
124+ assertUniqueNonzeroRowIDs(t, after)
125+ seen := make(map[RowID]struct{}, len(before))
126+ for _, id := range before {
127+ seen[id] = struct{}{}
128+ }
129+ for _, id := range after {
130+ if _, ok := seen[id]; ok {
131+ t.Fatalf("row ID %d was reused: before=%v after=%v", id, before, after)
132+ }
133+ }
134+}
@@ -0,0 +1,134 @@
1+package vt
2+
3+import "testing"
4+
5+func TestScreenRowIDsRotateWithRowsAndHistory(t *testing.T) {
6+ s := NewScreenWithHistory(4, 3, HistoryConfig{MaxRows: 8, ChunkRows: 2})
7+ initial := s.RowIDs()
8+ assertUniqueNonzeroRowIDs(t, initial)
9+
10+ s.Write([]byte("AAAA\r\nBBBB\r\nCCCC\r\n"))
11+
12+ live := s.RowIDs()
13+ if got, want := live, []RowID{initial[1], initial[2], live[2]}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
14+ t.Fatalf("live row IDs = %v, want rows moved from %v with a fresh blank row", got, initial)
15+ }
16+ if live[2] == initial[0] || live[2] == initial[1] || live[2] == initial[2] {
17+ t.Fatalf("new blank row reused a row ID: live=%v initial=%v", live, initial)
18+ }
19+ assertUniqueNonzeroRowIDs(t, live)
20+
21+ view := s.History().SealAndView()
22+ if got, want := view.RowID(0), initial[0]; got != want {
23+ t.Fatalf("evicted row ID = %d, want %d", got, want)
24+ }
25+ if got := view.FindRowID(initial[0]); got != 0 {
26+ t.Fatalf("FindRowID(%d) = %d, want 0", initial[0], got)
27+ }
28+}
29+
30+func TestScreenRowIDsDoNotAppendInteriorOrAlternateScrollHistory(t *testing.T) {
31+ s := NewScreenWithHistory(4, 4, HistoryConfig{MaxRows: 8})
32+ s.Write([]byte("AAAA\r\nBBBB\r\nCCCC\r\nDDDD"))
33+ s.Write([]byte("\x1b[2;4r\x1b[4;1H\n"))
34+ if got := s.History().Len(); got != 0 {
35+ t.Fatalf("interior scroll history rows = %d, want 0", got)
36+ }
37+ assertUniqueNonzeroRowIDs(t, s.RowIDs())
38+ primary := s.RowIDs()
39+
40+ s.Write([]byte("\x1b[?1049h1111\r\n2222\r\n3333\r\n4444\r\n"))
41+ if got := s.History().Len(); got != 0 {
42+ t.Fatalf("alternate scroll history rows = %d, want 0", got)
43+ }
44+ s.Write([]byte("\x1b[?1049l"))
45+ for i, id := range primary {
46+ if got := s.RowID(i); got != id {
47+ t.Fatalf("restored primary row %d ID = %d, want %d", i, got, id)
48+ }
49+ }
50+}
51+
52+func TestScreenRowIDsRefreshOnClearResetAndAlternateClone(t *testing.T) {
53+ s := NewScreen(4, 3)
54+ before := s.RowIDs()
55+ s.Write([]byte("AAAA\x1b[2J"))
56+ cleared := s.RowIDs()
57+ assertFreshRowIDs(t, before, cleared)
58+
59+ s.Write([]byte("\x1bc"))
60+ reset := s.RowIDs()
61+ assertFreshRowIDs(t, cleared, reset)
62+
63+ s.Write([]byte("BBBB\x1b[?1049h"))
64+ alternate := s.RowIDs()
65+ assertFreshRowIDs(t, reset, alternate)
66+ s.Write([]byte("CCCC\x1b[?1049l"))
67+ for i, id := range reset {
68+ if got := s.RowID(i); got != id {
69+ t.Fatalf("cloned primary row %d ID = %d, want %d", i, got, id)
70+ }
71+ }
72+}
73+
74+func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) {
75+ h := NewHistory(HistoryConfig{MaxRows: 3, ChunkRows: 2})
76+ for i, text := range []string{"one", "two"} {
77+ if err := h.Append(historyRow(text), LineBound{End: len(text)}, RowID(i+1)); err != nil {
78+ t.Fatal(err)
79+ }
80+ }
81+ before := h.SealAndView()
82+ if got, want := before.RowID(0), RowID(1); got != want {
83+ t.Fatalf("sealed row ID = %d, want %d", got, want)
84+ }
85+
86+ if err := h.Append(historyRow("three"), LineBound{End: 5}, RowID(3)); err != nil {
87+ t.Fatal(err)
88+ }
89+ if got := before.FindRowID(2); got != 1 {
90+ t.Fatalf("retained view FindRowID(2) = %d, want 1", got)
91+ }
92+ after := h.SealAndView()
93+ if got := after.FindRowID(3); got != 2 {
94+ t.Fatalf("new view FindRowID(3) = %d, want 2", got)
95+ }
96+
97+ if err := h.Append(historyRow("four"), LineBound{End: 4}, RowID(4)); err != nil {
98+ t.Fatal(err)
99+ }
100+ if got := before.FindRowID(1); got != 0 {
101+ t.Fatalf("eviction mutated retained view FindRowID(1) = %d, want 0", got)
102+ }
103+ if got := h.View().FindRowID(1); got != -1 {
104+ t.Fatalf("evicted current FindRowID(1) = %d, want -1", got)
105+ }
106+}
107+
108+func assertUniqueNonzeroRowIDs(t *testing.T, ids []RowID) {
109+ t.Helper()
110+ seen := make(map[RowID]struct{}, len(ids))
111+ for _, id := range ids {
112+ if id == 0 {
113+ t.Fatal("row ID is zero")
114+ }
115+ if _, ok := seen[id]; ok {
116+ t.Fatalf("duplicate row ID %d in %v", id, ids)
117+ }
118+ seen[id] = struct{}{}
119+ }
120+}
121+
122+func assertFreshRowIDs(t *testing.T, before, after []RowID) {
123+ t.Helper()
124+ assertUniqueNonzeroRowIDs(t, after)
125+ seen := make(map[RowID]struct{}, len(before))
126+ for _, id := range before {
127+ seen[id] = struct{}{}
128+ }
129+ for _, id := range after {
130+ if _, ok := seen[id]; ok {
131+ t.Fatalf("row ID %d was reused: before=%v after=%v", id, before, after)
132+ }
133+ }
134+}
M pkg/vt/screen.go +4 -3
@@ -53,6 +53,8 @@ type Screen struct {
5353 history *History
5454 buffer *buffer
5555
56+ nextRowID RowID
57+
5658 defaultFG renderer.RGB
5759 defaultBG renderer.RGB
5860 defaultColorsKnown bool
@@ -86,15 +88,14 @@ type Screen struct {
8688 }
8789
8890 func NewScreen(width, height int) *Screen {
89- b := newBuffer(width, height)
9091 s := &Screen{
91- Frame: b.frame,
92- buffer: b,
9392 Style: renderer.DefaultStyle(),
9493 damage: []renderer.Damage{renderer.FullRedraw()},
9594 damageGeneration: 1,
9695 cursorVisible: true,
9796 }
97+ s.buffer = s.newBuffer(width, height)
98+ s.Frame = s.buffer.frame
9899 s.resetScrollRegion()
99100 return s
100101 }
@@ -53,6 +53,8 @@ type Screen struct {
53 history *History53 history *History
54 buffer *buffer54 buffer *buffer
55 55
56+ nextRowID RowID
57+
56 defaultFG renderer.RGB58 defaultFG renderer.RGB
57 defaultBG renderer.RGB59 defaultBG renderer.RGB
58 defaultColorsKnown bool60 defaultColorsKnown bool
@@ -86,15 +88,14 @@ type Screen struct {
86 }88 }
87 89
88 func NewScreen(width, height int) *Screen {90 func NewScreen(width, height int) *Screen {
89- b := newBuffer(width, height)
90 s := &Screen{91 s := &Screen{
91- Frame: b.frame,
92- buffer: b,
93 Style: renderer.DefaultStyle(),92 Style: renderer.DefaultStyle(),
94 damage: []renderer.Damage{renderer.FullRedraw()},93 damage: []renderer.Damage{renderer.FullRedraw()},
95 damageGeneration: 1,94 damageGeneration: 1,
96 cursorVisible: true,95 cursorVisible: true,
97 }96 }
97+ s.buffer = s.newBuffer(width, height)
98+ s.Frame = s.buffer.frame
98 s.resetScrollRegion()99 s.resetScrollRegion()
99 return s100 return s
100 }101 }
M pkg/vt/screen_csi.go +1 -0
@@ -412,6 +412,7 @@ func (s *Screen) clearScreenMode(mode int) {
412412 }
413413 for y := range s.buffer.boundaries {
414414 s.buffer.boundaries[y] = LineBound{Soft: s.buffer.boundaries[y].Soft}
415+ s.buffer.rowIDs[y] = s.nextRowIDValue()
415416 }
416417 s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1})
417418 default:
@@ -412,6 +412,7 @@ func (s *Screen) clearScreenMode(mode int) {
412 }412 }
413 for y := range s.buffer.boundaries {413 for y := range s.buffer.boundaries {
414 s.buffer.boundaries[y] = LineBound{Soft: s.buffer.boundaries[y].Soft}414 s.buffer.boundaries[y] = LineBound{Soft: s.buffer.boundaries[y].Soft}
415+ s.buffer.rowIDs[y] = s.nextRowIDValue()
415 }416 }
416 s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1})417 s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1})
417 default:418 default:
M pkg/vt/screen_modes.go +3 -2
@@ -87,7 +87,7 @@ func (s *Screen) colorSchemeReport() []byte {
8787 }
8888
8989 func (s *Screen) reset() {
90- s.buffer = newBuffer(s.Frame.Width, s.Frame.Height)
90+ s.buffer = s.newBuffer(s.Frame.Width, s.Frame.Height)
9191 s.Frame = s.buffer.frame
9292 s.Row, s.Col = 0, 0
9393 s.Style = renderer.DefaultStyle()
@@ -203,7 +203,7 @@ func (s *Screen) enterAlternateScreen() {
203203 insertMode: s.insertMode,
204204 }
205205 }
206- s.buffer = newBuffer(s.Frame.Width, s.Frame.Height)
206+ s.buffer = s.newBuffer(s.Frame.Width, s.Frame.Height)
207207 s.Frame = s.buffer.frame
208208 s.Row, s.Col = 0, 0
209209 s.Style = renderer.DefaultStyle()
@@ -220,6 +220,7 @@ func (s *Screen) exitAlternateScreen() {
220220 s.buffer = state.buffer
221221 if s.buffer == nil {
222222 s.buffer = bufferFromFrame(cloneFrame(state.frame))
223+ s.fillMissingRowIDs(s.buffer)
223224 }
224225 s.Frame = s.buffer.frame
225226 s.Row = clamp(state.row, 0, s.Frame.Height-1)
@@ -87,7 +87,7 @@ func (s *Screen) colorSchemeReport() []byte {
87 }87 }
88 88
89 func (s *Screen) reset() {89 func (s *Screen) reset() {
90- s.buffer = newBuffer(s.Frame.Width, s.Frame.Height)90+ s.buffer = s.newBuffer(s.Frame.Width, s.Frame.Height)
91 s.Frame = s.buffer.frame91 s.Frame = s.buffer.frame
92 s.Row, s.Col = 0, 092 s.Row, s.Col = 0, 0
93 s.Style = renderer.DefaultStyle()93 s.Style = renderer.DefaultStyle()
@@ -203,7 +203,7 @@ func (s *Screen) enterAlternateScreen() {
203 insertMode: s.insertMode,203 insertMode: s.insertMode,
204 }204 }
205 }205 }
206- s.buffer = newBuffer(s.Frame.Width, s.Frame.Height)206+ s.buffer = s.newBuffer(s.Frame.Width, s.Frame.Height)
207 s.Frame = s.buffer.frame207 s.Frame = s.buffer.frame
208 s.Row, s.Col = 0, 0208 s.Row, s.Col = 0, 0
209 s.Style = renderer.DefaultStyle()209 s.Style = renderer.DefaultStyle()
@@ -220,6 +220,7 @@ func (s *Screen) exitAlternateScreen() {
220 s.buffer = state.buffer220 s.buffer = state.buffer
221 if s.buffer == nil {221 if s.buffer == nil {
222 s.buffer = bufferFromFrame(cloneFrame(state.frame))222 s.buffer = bufferFromFrame(cloneFrame(state.frame))
223+ s.fillMissingRowIDs(s.buffer)
223 }224 }
224 s.Frame = s.buffer.frame225 s.Frame = s.buffer.frame
225 s.Row = clamp(state.row, 0, s.Frame.Height-1)226 s.Row = clamp(state.row, 0, s.Frame.Height-1)
M pkg/vt/screen_resize.go +11 -4
@@ -10,10 +10,10 @@ func (s *Screen) Resize(width, height int) {
1010 // renderer.Frame permits a collapsed geometry. There is no layout to
1111 // preserve until a usable viewport is supplied again.
1212 width, height = max(width, 0), max(height, 0)
13- s.buffer = newBuffer(width, height)
13+ s.buffer = s.newBuffer(width, height)
1414 s.Row, s.Col = 0, 0
1515 if state := s.alternate; state != nil {
16- state.buffer = newBuffer(width, height)
16+ state.buffer = s.newBuffer(width, height)
1717 state.frame = state.buffer.frame
1818 state.row, state.col = 0, 0
1919 state.scrollTop, state.scrollBottom = 0, height-1
@@ -25,10 +25,16 @@ func (s *Screen) Resize(width, height int) {
2525 if saved != nil && saved.saved {
2626 savedPoint = &bufferCursor{row: saved.row, col: saved.col}
2727 }
28- evicted, evictedBounds := b.resize(width, height, active, savedPoint)
28+ evicted, evictedBounds, evictedIDs := b.resize(width, height, active, savedPoint)
29+ for i := range evictedIDs {
30+ if evictedIDs[i] == 0 {
31+ evictedIDs[i] = s.nextRowIDValue()
32+ }
33+ }
34+ s.fillMissingRowIDs(b)
2935 if evict {
3036 for i, line := range evicted {
31- s.recordEvicted(line, evictedBounds[i])
37+ s.recordEvicted(line, evictedBounds[i], evictedIDs[i])
3238 }
3339 }
3440 if savedPoint != nil {
@@ -44,6 +50,7 @@ func (s *Screen) Resize(width, height int) {
4450 state := s.alternate
4551 if state.buffer == nil {
4652 state.buffer = bufferFromFrame(state.frame)
53+ s.fillMissingRowIDs(state.buffer)
4754 }
4855 state.row, state.col = resize(state.buffer, state.row, state.col, &state.savedCursor, true)
4956 state.frame = state.buffer.frame
@@ -10,10 +10,10 @@ func (s *Screen) Resize(width, height int) {
10 // renderer.Frame permits a collapsed geometry. There is no layout to10 // renderer.Frame permits a collapsed geometry. There is no layout to
11 // preserve until a usable viewport is supplied again.11 // preserve until a usable viewport is supplied again.
12 width, height = max(width, 0), max(height, 0)12 width, height = max(width, 0), max(height, 0)
13- s.buffer = newBuffer(width, height)13+ s.buffer = s.newBuffer(width, height)
14 s.Row, s.Col = 0, 014 s.Row, s.Col = 0, 0
15 if state := s.alternate; state != nil {15 if state := s.alternate; state != nil {
16- state.buffer = newBuffer(width, height)16+ state.buffer = s.newBuffer(width, height)
17 state.frame = state.buffer.frame17 state.frame = state.buffer.frame
18 state.row, state.col = 0, 018 state.row, state.col = 0, 0
19 state.scrollTop, state.scrollBottom = 0, height-119 state.scrollTop, state.scrollBottom = 0, height-1
@@ -25,10 +25,16 @@ func (s *Screen) Resize(width, height int) {
25 if saved != nil && saved.saved {25 if saved != nil && saved.saved {
26 savedPoint = &bufferCursor{row: saved.row, col: saved.col}26 savedPoint = &bufferCursor{row: saved.row, col: saved.col}
27 }27 }
28- evicted, evictedBounds := b.resize(width, height, active, savedPoint)28+ evicted, evictedBounds, evictedIDs := b.resize(width, height, active, savedPoint)
29+ for i := range evictedIDs {
30+ if evictedIDs[i] == 0 {
31+ evictedIDs[i] = s.nextRowIDValue()
32+ }
33+ }
34+ s.fillMissingRowIDs(b)
29 if evict {35 if evict {
30 for i, line := range evicted {36 for i, line := range evicted {
31- s.recordEvicted(line, evictedBounds[i])37+ s.recordEvicted(line, evictedBounds[i], evictedIDs[i])
32 }38 }
33 }39 }
34 if savedPoint != nil {40 if savedPoint != nil {
@@ -44,6 +50,7 @@ func (s *Screen) Resize(width, height int) {
44 state := s.alternate50 state := s.alternate
45 if state.buffer == nil {51 if state.buffer == nil {
46 state.buffer = bufferFromFrame(state.frame)52 state.buffer = bufferFromFrame(state.frame)
53+ s.fillMissingRowIDs(state.buffer)
47 }54 }
48 state.row, state.col = resize(state.buffer, state.row, state.col, &state.savedCursor, true)55 state.row, state.col = resize(state.buffer, state.row, state.col, &state.savedCursor, true)
49 state.frame = state.buffer.frame56 state.frame = state.buffer.frame
M pkg/vt/screen_scroll.go +7 -5
@@ -86,6 +86,7 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) (shifted bool) {
8686 s.emitLineEvicted(top, n)
8787 s.Frame.ScrollUp(top, bottom, n)
8888 s.buffer.scrollUp(top, bottom, n)
89+ s.fillMissingRowIDs(s.buffer)
8990 s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n})
9091 s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1})
9192 return bottom-n >= top
@@ -97,16 +98,16 @@ func (s *Screen) emitLineEvicted(top, n int) {
9798 if s.alternate != nil || top != 0 {
9899 return
99100 }
100- // Read boundaries before the caller rotates the frame: a soft link belongs
101- // to the row it follows, and rotation reassigns row indices.
101+ // Read boundaries and IDs before the caller rotates the frame: a soft link
102+ // belongs to the row it follows, and rotation reassigns row indices.
102103 for y := top; y < top+n; y++ {
103- s.recordEvicted(s.Frame.Row(y), s.buffer.bound(y))
104+ s.recordEvicted(s.Frame.Row(y), s.buffer.bound(y), s.buffer.rowIDs[y])
104105 }
105106 }
106107
107-func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound) {
108+func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound, id RowID) {
108109 if s.history != nil {
109- err := s.history.Append(row, bound)
110+ err := s.history.Append(row, bound, id)
110111 if err != nil && !errors.Is(err, ErrHistoryRowTooWide) {
111112 panic(err)
112113 }
@@ -134,6 +135,7 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) {
134135 // Full-width region: rotate line offsets instead of copying cells.
135136 s.Frame.ScrollDown(top, bottom, n)
136137 s.buffer.scrollDown(top, bottom, n)
138+ s.fillMissingRowIDs(s.buffer)
137139 s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1})
138140 }
139141
@@ -86,6 +86,7 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) (shifted bool) {
86 s.emitLineEvicted(top, n)86 s.emitLineEvicted(top, n)
87 s.Frame.ScrollUp(top, bottom, n)87 s.Frame.ScrollUp(top, bottom, n)
88 s.buffer.scrollUp(top, bottom, n)88 s.buffer.scrollUp(top, bottom, n)
89+ s.fillMissingRowIDs(s.buffer)
89 s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n})90 s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n})
90 s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1})91 s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1})
91 return bottom-n >= top92 return bottom-n >= top
@@ -97,16 +98,16 @@ func (s *Screen) emitLineEvicted(top, n int) {
97 if s.alternate != nil || top != 0 {98 if s.alternate != nil || top != 0 {
98 return99 return
99 }100 }
100- // Read boundaries before the caller rotates the frame: a soft link belongs101+ // Read boundaries and IDs before the caller rotates the frame: a soft link
101- // to the row it follows, and rotation reassigns row indices.102+ // belongs to the row it follows, and rotation reassigns row indices.
102 for y := top; y < top+n; y++ {103 for y := top; y < top+n; y++ {
103- s.recordEvicted(s.Frame.Row(y), s.buffer.bound(y))104+ s.recordEvicted(s.Frame.Row(y), s.buffer.bound(y), s.buffer.rowIDs[y])
104 }105 }
105 }106 }
106 107
107-func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound) {108+func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound, id RowID) {
108 if s.history != nil {109 if s.history != nil {
109- err := s.history.Append(row, bound)110+ err := s.history.Append(row, bound, id)
110 if err != nil && !errors.Is(err, ErrHistoryRowTooWide) {111 if err != nil && !errors.Is(err, ErrHistoryRowTooWide) {
111 panic(err)112 panic(err)
112 }113 }
@@ -134,6 +135,7 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) {
134 // Full-width region: rotate line offsets instead of copying cells.135 // Full-width region: rotate line offsets instead of copying cells.
135 s.Frame.ScrollDown(top, bottom, n)136 s.Frame.ScrollDown(top, bottom, n)
136 s.buffer.scrollDown(top, bottom, n)137 s.buffer.scrollDown(top, bottom, n)
138+ s.fillMissingRowIDs(s.buffer)
137 s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1})139 s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1})
138 }140 }
139 141
M pkg/vt/screen_write.go +4 -0
@@ -184,6 +184,7 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
184184 if y < 0 || y >= s.Frame.Height {
185185 return x0, 0
186186 }
187+ fullRow := x0 <= 0 && x1 >= s.Frame.Width
187188 if x0 < 0 {
188189 x0 = 0
189190 }
@@ -207,6 +208,9 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
207208 s.Frame.Set(x, y, blank)
208209 }
209210 s.buffer.clear(y, x0, x1)
211+ if fullRow {
212+ s.buffer.rowIDs[y] = s.nextRowIDValue()
213+ }
210214 return x0, x1 - x0
211215 }
212216
@@ -184,6 +184,7 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
184 if y < 0 || y >= s.Frame.Height {184 if y < 0 || y >= s.Frame.Height {
185 return x0, 0185 return x0, 0
186 }186 }
187+ fullRow := x0 <= 0 && x1 >= s.Frame.Width
187 if x0 < 0 {188 if x0 < 0 {
188 x0 = 0189 x0 = 0
189 }190 }
@@ -207,6 +208,9 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
207 s.Frame.Set(x, y, blank)208 s.Frame.Set(x, y, blank)
208 }209 }
209 s.buffer.clear(y, x0, x1)210 s.buffer.clear(y, x0, x1)
211+ if fullRow {
212+ s.buffer.rowIDs[y] = s.nextRowIDValue()
213+ }
210 return x0, x1 - x0214 return x0, x1 - x0
211 }215 }
212 216
M pkg/vt/snapshot.go +9 -0
@@ -83,6 +83,7 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist
8383 return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory)
8484 }
8585 h.evictUntil(len(view.chunks[0].rows), view.Cells())
86+ h.assignChunkIDs(view.chunks[0])
8687 h.chunks = append(h.chunks, view.chunks[0])
8788 h.rows += len(view.chunks[0].rows)
8889 h.cells += view.Cells()
@@ -95,6 +96,14 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist
9596 h.evictUntil(len(view.chunks[0].rows), view.Cells())
9697 h.tail = view.chunks[0].rows
9798 h.tailBounds = growBounds(view.chunks[0].bounds, len(h.tail))
99+ h.tailIDs = growRowIDs(view.chunks[0].rowIDs, len(h.tail))
100+ for i := range h.tailIDs {
101+ if h.tailIDs[i] == 0 || h.hasRowID(h.tailIDs[i]) {
102+ h.tailIDs[i] = h.allocateRowID()
103+ } else if h.tailIDs[i] >= h.nextRowID {
104+ h.nextRowID = h.tailIDs[i] + 1
105+ }
106+ }
98107 h.rows += len(h.tail)
99108 h.cells += view.Cells()
100109 }
@@ -83,6 +83,7 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist
83 return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory)83 return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory)
84 }84 }
85 h.evictUntil(len(view.chunks[0].rows), view.Cells())85 h.evictUntil(len(view.chunks[0].rows), view.Cells())
86+ h.assignChunkIDs(view.chunks[0])
86 h.chunks = append(h.chunks, view.chunks[0])87 h.chunks = append(h.chunks, view.chunks[0])
87 h.rows += len(view.chunks[0].rows)88 h.rows += len(view.chunks[0].rows)
88 h.cells += view.Cells()89 h.cells += view.Cells()
@@ -95,6 +96,14 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist
95 h.evictUntil(len(view.chunks[0].rows), view.Cells())96 h.evictUntil(len(view.chunks[0].rows), view.Cells())
96 h.tail = view.chunks[0].rows97 h.tail = view.chunks[0].rows
97 h.tailBounds = growBounds(view.chunks[0].bounds, len(h.tail))98 h.tailBounds = growBounds(view.chunks[0].bounds, len(h.tail))
99+ h.tailIDs = growRowIDs(view.chunks[0].rowIDs, len(h.tail))
100+ for i := range h.tailIDs {
101+ if h.tailIDs[i] == 0 || h.hasRowID(h.tailIDs[i]) {
102+ h.tailIDs[i] = h.allocateRowID()
103+ } else if h.tailIDs[i] >= h.nextRowID {
104+ h.nextRowID = h.tailIDs[i] + 1
105+ }
106+ }
98 h.rows += len(h.tail)107 h.rows += len(h.tail)
99 h.cells += view.Cells()108 h.cells += view.Cells()
100 }109 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close