detached cc001e4

fix(vt): preserve row identities through restore

commit
cc001e48951f6aa223f263240eb3e1f6a4d086bd
author
brice · · 2026-08-02 20:53 UTC
committer
brice · · 2026-08-03 17:54 UTC
parent
7560131
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +195 -61
M internal/usecase/copy/mode.go +18 -4
@@ -25,14 +25,28 @@ type Snapshot struct {
2525 }
2626
2727 func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, rowIDs ...[]vt.RowID) Snapshot {
28- var history vt.HistoryView
29- if historySource != nil {
30- history = historySource.SealAndView()
31- }
3228 var screenRowIDs []vt.RowID
3329 if len(rowIDs) > 0 {
3430 screenRowIDs = append([]vt.RowID(nil), rowIDs[0]...)
3531 }
32+ return newSnapshot(historySource, screen, bounds, screenRowIDs)
33+}
34+
35+// NewSnapshotFromScreen captures a screen while its owner holds the Screen
36+// lock. Screen.RowIDs already returns owned storage, so the snapshot can take
37+// that copy directly without allocating a duplicate metadata slice.
38+func NewSnapshotFromScreen(historySource *vt.History, screen *vt.Screen) Snapshot {
39+ if screen == nil {
40+ return NewSnapshot(historySource, renderer.Frame{}, nil)
41+ }
42+ return newSnapshot(historySource, screen.Frame, screen.LineBounds(), screen.RowIDs())
43+}
44+
45+func newSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, screenRowIDs []vt.RowID) Snapshot {
46+ var history vt.HistoryView
47+ if historySource != nil {
48+ history = historySource.SealAndView()
49+ }
3650 return Snapshot{
3751 history: history,
3852 screen: screen.Clone(),
@@ -25,14 +25,28 @@ type Snapshot struct {
25 }25 }
26 26
27 func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, rowIDs ...[]vt.RowID) Snapshot {27 func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, rowIDs ...[]vt.RowID) Snapshot {
28- var history vt.HistoryView
29- if historySource != nil {
30- history = historySource.SealAndView()
31- }
32 var screenRowIDs []vt.RowID28 var screenRowIDs []vt.RowID
33 if len(rowIDs) > 0 {29 if len(rowIDs) > 0 {
34 screenRowIDs = append([]vt.RowID(nil), rowIDs[0]...)30 screenRowIDs = append([]vt.RowID(nil), rowIDs[0]...)
35 }31 }
32+ return newSnapshot(historySource, screen, bounds, screenRowIDs)
33+}
34+
35+// NewSnapshotFromScreen captures a screen while its owner holds the Screen
36+// lock. Screen.RowIDs already returns owned storage, so the snapshot can take
37+// that copy directly without allocating a duplicate metadata slice.
38+func NewSnapshotFromScreen(historySource *vt.History, screen *vt.Screen) Snapshot {
39+ if screen == nil {
40+ return NewSnapshot(historySource, renderer.Frame{}, nil)
41+ }
42+ return newSnapshot(historySource, screen.Frame, screen.LineBounds(), screen.RowIDs())
43+}
44+
45+func newSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, screenRowIDs []vt.RowID) Snapshot {
46+ var history vt.HistoryView
47+ if historySource != nil {
48+ history = historySource.SealAndView()
49+ }
36 return Snapshot{50 return Snapshot{
37 history: history,51 history: history,
38 screen: screen.Clone(),52 screen: screen.Clone(),
M internal/usecase/daemon/copy_mouse_input.go +1 -1
@@ -68,7 +68,7 @@ func (d *Daemon) handleFreshCopyPress(sess *session, ac *attachedClient, tb *tab
6868 geometry.pane.mu.Lock()
6969 mouseMode, _ := geometry.pane.screen.MouseMode()
7070 altScreen := geometry.pane.screen.AltScreenActive()
71- snapshot := scopy.NewSnapshot(geometry.pane.history, geometry.pane.screen.Frame, geometry.pane.screen.LineBounds(), geometry.pane.screen.RowIDs())
71+ snapshot := scopy.NewSnapshotFromScreen(geometry.pane.history, geometry.pane.screen)
7272 geometry.pane.mu.Unlock()
7373 if mouseMode != 0 || altScreen {
7474 return false // child forwarding retains its existing raw-byte path.
@@ -68,7 +68,7 @@ func (d *Daemon) handleFreshCopyPress(sess *session, ac *attachedClient, tb *tab
68 geometry.pane.mu.Lock()68 geometry.pane.mu.Lock()
69 mouseMode, _ := geometry.pane.screen.MouseMode()69 mouseMode, _ := geometry.pane.screen.MouseMode()
70 altScreen := geometry.pane.screen.AltScreenActive()70 altScreen := geometry.pane.screen.AltScreenActive()
71- snapshot := scopy.NewSnapshot(geometry.pane.history, geometry.pane.screen.Frame, geometry.pane.screen.LineBounds(), geometry.pane.screen.RowIDs())71+ snapshot := scopy.NewSnapshotFromScreen(geometry.pane.history, geometry.pane.screen)
72 geometry.pane.mu.Unlock()72 geometry.pane.mu.Unlock()
73 if mouseMode != 0 || altScreen {73 if mouseMode != 0 || altScreen {
74 return false // child forwarding retains its existing raw-byte path.74 return false // child forwarding retains its existing raw-byte path.
M internal/usecase/daemon/copymode.go +1 -1
@@ -74,7 +74,7 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
7474 return
7575 }
7676 p.mu.Lock()
77- snapshot := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), p.screen.RowIDs())
77+ snapshot := scopy.NewSnapshotFromScreen(p.history, p.screen)
7878 p.mu.Unlock()
7979 cfg := d.currentCopyConfig()
8080 document := scopy.NewDocument(snapshot, cfg.WordSeparators)
@@ -74,7 +74,7 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
74 return74 return
75 }75 }
76 p.mu.Lock()76 p.mu.Lock()
77- snapshot := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), p.screen.RowIDs())77+ snapshot := scopy.NewSnapshotFromScreen(p.history, p.screen)
78 p.mu.Unlock()78 p.mu.Unlock()
79 cfg := d.currentCopyConfig()79 cfg := d.currentCopyConfig()
80 document := scopy.NewDocument(snapshot, cfg.WordSeparators)80 document := scopy.NewDocument(snapshot, cfg.WordSeparators)
M pkg/vt/buffer.go +26 -3
@@ -260,7 +260,7 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]ren
260260 }
261261
262262 points := b.cursorReflowPoints(active, saved)
263- rows := b.layoutReflow(width, &points, nil, 0, nil, nil)
263+ rows := b.layoutReflow(width, &points, nil, 0, nil, nil, nil, nil)
264264 anchor := 0
265265 if active != nil {
266266 anchor = points[0].row
@@ -283,7 +283,7 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]ren
283283 evicted[y] = evictedCells[y*width : (y+1)*width]
284284 }
285285 }
286- b.layoutReflow(width, &points, next, shift, evictedCells, evictedBounds)
286+ b.layoutReflow(width, &points, next, shift, b.rowIDs, evictedCells, evictedBounds, evictedIDs)
287287 for i, cur := range [2]*bufferCursor{active, saved} {
288288 if cur != nil {
289289 cur.row = clamp(points[i].row-shift, 0, height-1)
@@ -297,8 +297,21 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]ren
297297 // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in
298298 // [shift, shift+dst.height). Rows before shift are written straight into the
299299 // contiguous eviction backing store. The return is the logical output height.
300-func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, evicted []renderer.Cell, evictedBounds []LineBound) int {
300+func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, sourceIDs []RowID, evicted []renderer.Cell, evictedBounds []LineBound, evictedIDs []RowID) int {
301301 row, col := 0, 0
302+ setRowID := func(outputRow int, id RowID) {
303+ if id == 0 {
304+ return
305+ }
306+ switch {
307+ case outputRow < shift:
308+ if outputRow < len(evictedIDs) {
309+ evictedIDs[outputRow] = id
310+ }
311+ case dst != nil && outputRow < shift+dst.frame.Height:
312+ dst.rowIDs[outputRow-shift] = id
313+ }
314+ }
302315 blankEvicted := func(outputRow int) []renderer.Cell {
303316 if outputRow < shift {
304317 out := evicted[outputRow*width : (outputRow+1)*width]
@@ -356,6 +369,16 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh
356369 }
357370 reflow := end > start
358371 offset := 0
372+ sourceID := RowID(0)
373+ if start < len(sourceIDs) {
374+ sourceID = sourceIDs[start]
375+ }
376+ // A source row that starts in a partially filled output row has already
377+ // inherited that row's identity. Continuation rows created by wrapping
378+ // receive fresh IDs from the owning Screen after this pass.
379+ if col == 0 {
380+ setRowID(row, sourceID)
381+ }
359382 truncated := false
360383 for y := start; y <= end && !truncated; y++ {
361384 cells := b.frame.Row(y)
@@ -260,7 +260,7 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]ren
260 }260 }
261 261
262 points := b.cursorReflowPoints(active, saved)262 points := b.cursorReflowPoints(active, saved)
263- rows := b.layoutReflow(width, &points, nil, 0, nil, nil)263+ rows := b.layoutReflow(width, &points, nil, 0, nil, nil, nil, nil)
264 anchor := 0264 anchor := 0
265 if active != nil {265 if active != nil {
266 anchor = points[0].row266 anchor = points[0].row
@@ -283,7 +283,7 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]ren
283 evicted[y] = evictedCells[y*width : (y+1)*width]283 evicted[y] = evictedCells[y*width : (y+1)*width]
284 }284 }
285 }285 }
286- b.layoutReflow(width, &points, next, shift, evictedCells, evictedBounds)286+ b.layoutReflow(width, &points, next, shift, b.rowIDs, evictedCells, evictedBounds, evictedIDs)
287 for i, cur := range [2]*bufferCursor{active, saved} {287 for i, cur := range [2]*bufferCursor{active, saved} {
288 if cur != nil {288 if cur != nil {
289 cur.row = clamp(points[i].row-shift, 0, height-1)289 cur.row = clamp(points[i].row-shift, 0, height-1)
@@ -297,8 +297,21 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]ren
297 // 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
298 // [shift, shift+dst.height). Rows before shift are written straight into the298 // [shift, shift+dst.height). Rows before shift are written straight into the
299 // contiguous eviction backing store. The return is the logical output height.299 // contiguous eviction backing store. The return is the logical output height.
300-func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, evicted []renderer.Cell, evictedBounds []LineBound) int {300+func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, sourceIDs []RowID, evicted []renderer.Cell, evictedBounds []LineBound, evictedIDs []RowID) int {
301 row, col := 0, 0301 row, col := 0, 0
302+ setRowID := func(outputRow int, id RowID) {
303+ if id == 0 {
304+ return
305+ }
306+ switch {
307+ case outputRow < shift:
308+ if outputRow < len(evictedIDs) {
309+ evictedIDs[outputRow] = id
310+ }
311+ case dst != nil && outputRow < shift+dst.frame.Height:
312+ dst.rowIDs[outputRow-shift] = id
313+ }
314+ }
302 blankEvicted := func(outputRow int) []renderer.Cell {315 blankEvicted := func(outputRow int) []renderer.Cell {
303 if outputRow < shift {316 if outputRow < shift {
304 out := evicted[outputRow*width : (outputRow+1)*width]317 out := evicted[outputRow*width : (outputRow+1)*width]
@@ -356,6 +369,16 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh
356 }369 }
357 reflow := end > start370 reflow := end > start
358 offset := 0371 offset := 0
372+ sourceID := RowID(0)
373+ if start < len(sourceIDs) {
374+ sourceID = sourceIDs[start]
375+ }
376+ // A source row that starts in a partially filled output row has already
377+ // inherited that row's identity. Continuation rows created by wrapping
378+ // receive fresh IDs from the owning Screen after this pass.
379+ if col == 0 {
380+ setRowID(row, sourceID)
381+ }
359 truncated := false382 truncated := false
360 for y := start; y <= end && !truncated; y++ {383 for y := start; y <= end && !truncated; y++ {
361 cells := b.frame.Row(y)384 cells := b.frame.Row(y)
M pkg/vt/history.go +29 -21
@@ -3,6 +3,7 @@ package vt
33 import (
44 "errors"
55 "math"
6+ "slices"
67
78 "github.com/bnema/vev/pkg/renderer"
89 )
@@ -107,7 +108,11 @@ func (h *History) Append(row []renderer.Cell, bound LineBound, ids ...RowID) err
107108 id = ids[0]
108109 }
109110 if id == 0 || h.hasRowID(id) {
110- id = h.allocateRowID()
111+ var err error
112+ id, err = h.allocateRowID()
113+ if err != nil {
114+ return err
115+ }
111116 }
112117 return h.appendRow(row, bound, id)
113118 }
@@ -118,17 +123,20 @@ func (h *History) appendRestored(row []renderer.Cell, bound LineBound, id RowID)
118123 if h == nil || h.maxRows == 0 {
119124 return nil
120125 }
121- if id == 0 || id == ^RowID(0) || h.hasRowID(id) {
126+ if id == 0 || id >= ^RowID(0)-1 || h.hasRowID(id) {
122127 return errInvalidHistoryRowID
123128 }
124129 return h.appendRow(row, bound, id)
125130 }
126131
127132 func (h *History) appendRow(row []renderer.Cell, bound LineBound, id RowID) error {
133+ if h.nextRowID == ^RowID(0) {
134+ return errInvalidHistoryRowID
135+ }
128136 if len(row) > h.maxCells {
129137 return ErrHistoryRowTooWide
130138 }
131- if id == ^RowID(0) {
139+ if id >= ^RowID(0)-1 {
132140 return errInvalidHistoryRowID
133141 }
134142 if id >= h.nextRowID {
@@ -148,38 +156,31 @@ func (h *History) appendRow(row []renderer.Cell, bound LineBound, id RowID) erro
148156 return nil
149157 }
150158
151-func (h *History) allocateRowID() RowID {
159+func (h *History) allocateRowID() (RowID, error) {
152160 if h.nextRowID == 0 {
153161 h.nextRowID = 1
154162 }
155163 for h.hasRowID(h.nextRowID) {
156- if h.nextRowID == ^RowID(0) {
157- panic("vt: history row ID space exhausted")
164+ if h.nextRowID >= ^RowID(0)-1 {
165+ return 0, errInvalidHistoryRowID
158166 }
159167 h.nextRowID++
160168 }
161169 id := h.nextRowID
162- if h.nextRowID == ^RowID(0) {
163- panic("vt: history row ID space exhausted")
170+ if h.nextRowID >= ^RowID(0)-1 {
171+ return 0, errInvalidHistoryRowID
164172 }
165173 h.nextRowID++
166- return id
174+ return id, nil
167175 }
168176
169177 func (h *History) hasRowID(id RowID) bool {
170178 for _, chunk := range h.chunks {
171- for _, candidate := range chunk.rowIDs {
172- if candidate == id {
173- return true
174- }
175- }
176- }
177- for _, candidate := range h.tailIDs {
178- if candidate == id {
179+ if slices.Contains(chunk.rowIDs, id) {
179180 return true
180181 }
181182 }
182- return false
183+ return slices.Contains(h.tailIDs, id)
183184 }
184185
185186 // clampBound keeps End inside the row it describes so a persisted bound can be
@@ -209,6 +210,12 @@ func growRowIDs(ids []RowID, rows int) []RowID {
209210 return out
210211 }
211212
213+func cloneRowIDs(ids []RowID, rows int) []RowID {
214+ out := make([]RowID, rows)
215+ copy(out, ids)
216+ return out
217+}
218+
212219 func (h *History) sealTail() {
213220 if len(h.tail) == 0 {
214221 return
@@ -307,12 +314,13 @@ func (h *History) View() HistoryView {
307314 if h.rows == 0 {
308315 return HistoryView{nextRowID: h.nextRowID}
309316 }
310- chunks := append([]*HistoryChunk(nil), h.chunks...)
317+ chunks := make([]*HistoryChunk, len(h.chunks), len(h.chunks)+1)
318+ copy(chunks, h.chunks)
311319 if len(h.tail) > 0 {
312320 chunks = append(chunks, &HistoryChunk{
313321 rows: cloneHistoryRows(h.tail),
314322 bounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),
315- rowIDs: growRowIDs(h.tailIDs, len(h.tail)),
323+ rowIDs: cloneRowIDs(h.tailIDs, len(h.tail)),
316324 })
317325 }
318326 return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells, nextRowID: h.nextRowID}
@@ -331,7 +339,7 @@ func (h *History) SnapshotView() HistorySnapshotView {
331339 chunks: append([]*HistoryChunk(nil), h.chunks...),
332340 tail: cloneHistoryRows(h.tail),
333341 tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),
334- tailIDs: growRowIDs(h.tailIDs, len(h.tail)),
342+ tailIDs: cloneRowIDs(h.tailIDs, len(h.tail)),
335343 rows: h.rows,
336344 cells: h.cells,
337345 nextRowID: h.nextRowID,
@@ -3,6 +3,7 @@ package vt
3 import (3 import (
4 "errors"4 "errors"
5 "math"5 "math"
6+ "slices"
6 7
7 "github.com/bnema/vev/pkg/renderer"8 "github.com/bnema/vev/pkg/renderer"
8 )9 )
@@ -107,7 +108,11 @@ func (h *History) Append(row []renderer.Cell, bound LineBound, ids ...RowID) err
107 id = ids[0]108 id = ids[0]
108 }109 }
109 if id == 0 || h.hasRowID(id) {110 if id == 0 || h.hasRowID(id) {
110- id = h.allocateRowID()111+ var err error
112+ id, err = h.allocateRowID()
113+ if err != nil {
114+ return err
115+ }
111 }116 }
112 return h.appendRow(row, bound, id)117 return h.appendRow(row, bound, id)
113 }118 }
@@ -118,17 +123,20 @@ func (h *History) appendRestored(row []renderer.Cell, bound LineBound, id RowID)
118 if h == nil || h.maxRows == 0 {123 if h == nil || h.maxRows == 0 {
119 return nil124 return nil
120 }125 }
121- if id == 0 || id == ^RowID(0) || h.hasRowID(id) {126+ if id == 0 || id >= ^RowID(0)-1 || h.hasRowID(id) {
122 return errInvalidHistoryRowID127 return errInvalidHistoryRowID
123 }128 }
124 return h.appendRow(row, bound, id)129 return h.appendRow(row, bound, id)
125 }130 }
126 131
127 func (h *History) appendRow(row []renderer.Cell, bound LineBound, id RowID) error {132 func (h *History) appendRow(row []renderer.Cell, bound LineBound, id RowID) error {
133+ if h.nextRowID == ^RowID(0) {
134+ return errInvalidHistoryRowID
135+ }
128 if len(row) > h.maxCells {136 if len(row) > h.maxCells {
129 return ErrHistoryRowTooWide137 return ErrHistoryRowTooWide
130 }138 }
131- if id == ^RowID(0) {139+ if id >= ^RowID(0)-1 {
132 return errInvalidHistoryRowID140 return errInvalidHistoryRowID
133 }141 }
134 if id >= h.nextRowID {142 if id >= h.nextRowID {
@@ -148,38 +156,31 @@ func (h *History) appendRow(row []renderer.Cell, bound LineBound, id RowID) erro
148 return nil156 return nil
149 }157 }
150 158
151-func (h *History) allocateRowID() RowID {159+func (h *History) allocateRowID() (RowID, error) {
152 if h.nextRowID == 0 {160 if h.nextRowID == 0 {
153 h.nextRowID = 1161 h.nextRowID = 1
154 }162 }
155 for h.hasRowID(h.nextRowID) {163 for h.hasRowID(h.nextRowID) {
156- if h.nextRowID == ^RowID(0) {164+ if h.nextRowID >= ^RowID(0)-1 {
157- panic("vt: history row ID space exhausted")165+ return 0, errInvalidHistoryRowID
158 }166 }
159 h.nextRowID++167 h.nextRowID++
160 }168 }
161 id := h.nextRowID169 id := h.nextRowID
162- if h.nextRowID == ^RowID(0) {170+ if h.nextRowID >= ^RowID(0)-1 {
163- panic("vt: history row ID space exhausted")171+ return 0, errInvalidHistoryRowID
164 }172 }
165 h.nextRowID++173 h.nextRowID++
166- return id174+ return id, nil
167 }175 }
168 176
169 func (h *History) hasRowID(id RowID) bool {177 func (h *History) hasRowID(id RowID) bool {
170 for _, chunk := range h.chunks {178 for _, chunk := range h.chunks {
171- for _, candidate := range chunk.rowIDs {179+ if slices.Contains(chunk.rowIDs, id) {
172- if candidate == id {
173- return true
174- }
175- }
176- }
177- for _, candidate := range h.tailIDs {
178- if candidate == id {
179 return true180 return true
180 }181 }
181 }182 }
182- return false183+ return slices.Contains(h.tailIDs, id)
183 }184 }
184 185
185 // clampBound keeps End inside the row it describes so a persisted bound can be186 // clampBound keeps End inside the row it describes so a persisted bound can be
@@ -209,6 +210,12 @@ func growRowIDs(ids []RowID, rows int) []RowID {
209 return out210 return out
210 }211 }
211 212
213+func cloneRowIDs(ids []RowID, rows int) []RowID {
214+ out := make([]RowID, rows)
215+ copy(out, ids)
216+ return out
217+}
218+
212 func (h *History) sealTail() {219 func (h *History) sealTail() {
213 if len(h.tail) == 0 {220 if len(h.tail) == 0 {
214 return221 return
@@ -307,12 +314,13 @@ func (h *History) View() HistoryView {
307 if h.rows == 0 {314 if h.rows == 0 {
308 return HistoryView{nextRowID: h.nextRowID}315 return HistoryView{nextRowID: h.nextRowID}
309 }316 }
310- chunks := append([]*HistoryChunk(nil), h.chunks...)317+ chunks := make([]*HistoryChunk, len(h.chunks), len(h.chunks)+1)
318+ copy(chunks, h.chunks)
311 if len(h.tail) > 0 {319 if len(h.tail) > 0 {
312 chunks = append(chunks, &HistoryChunk{320 chunks = append(chunks, &HistoryChunk{
313 rows: cloneHistoryRows(h.tail),321 rows: cloneHistoryRows(h.tail),
314 bounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),322 bounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),
315- rowIDs: growRowIDs(h.tailIDs, len(h.tail)),323+ rowIDs: cloneRowIDs(h.tailIDs, len(h.tail)),
316 })324 })
317 }325 }
318 return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells, nextRowID: h.nextRowID}326 return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells, nextRowID: h.nextRowID}
@@ -331,7 +339,7 @@ func (h *History) SnapshotView() HistorySnapshotView {
331 chunks: append([]*HistoryChunk(nil), h.chunks...),339 chunks: append([]*HistoryChunk(nil), h.chunks...),
332 tail: cloneHistoryRows(h.tail),340 tail: cloneHistoryRows(h.tail),
333 tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),341 tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),
334- tailIDs: growRowIDs(h.tailIDs, len(h.tail)),342+ tailIDs: cloneRowIDs(h.tailIDs, len(h.tail)),
335 rows: h.rows,343 rows: h.rows,
336 cells: h.cells,344 cells: h.cells,
337 nextRowID: h.nextRowID,345 nextRowID: h.nextRowID,
M pkg/vt/history_codec.go +3 -3
@@ -53,7 +53,7 @@ func MarshalHistory(view HistoryView) ([]byte, error) {
5353 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
5454 }
5555 id := chunk.rowIDs[i]
56- if id == 0 || id == ^RowID(0) {
56+ if id == 0 || id >= ^RowID(0)-1 {
5757 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
5858 }
5959 if _, duplicate := seen[id]; duplicate {
@@ -109,7 +109,7 @@ func historyViewNextRowID(view HistoryView) (RowID, bool) {
109109 return 0, false
110110 }
111111 for _, id := range chunk.rowIDs {
112- if id == 0 || id == ^RowID(0) {
112+ if id == 0 || id >= ^RowID(0)-1 {
113113 return 0, false
114114 }
115115 if _, duplicate := seen[id]; duplicate {
@@ -285,7 +285,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
285285 }
286286 for range rowCount {
287287 id, ok := p.uint64()
288- if !ok || id == 0 || id == ^uint64(0) {
288+ if !ok || id == 0 || id >= uint64(^RowID(0)-1) {
289289 return HistoryView{}, historyDecodeStats{}, false
290290 }
291291 rowID := RowID(id)
@@ -53,7 +53,7 @@ func MarshalHistory(view HistoryView) ([]byte, error) {
53 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)53 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
54 }54 }
55 id := chunk.rowIDs[i]55 id := chunk.rowIDs[i]
56- if id == 0 || id == ^RowID(0) {56+ if id == 0 || id >= ^RowID(0)-1 {
57 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)57 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
58 }58 }
59 if _, duplicate := seen[id]; duplicate {59 if _, duplicate := seen[id]; duplicate {
@@ -109,7 +109,7 @@ func historyViewNextRowID(view HistoryView) (RowID, bool) {
109 return 0, false109 return 0, false
110 }110 }
111 for _, id := range chunk.rowIDs {111 for _, id := range chunk.rowIDs {
112- if id == 0 || id == ^RowID(0) {112+ if id == 0 || id >= ^RowID(0)-1 {
113 return 0, false113 return 0, false
114 }114 }
115 if _, duplicate := seen[id]; duplicate {115 if _, duplicate := seen[id]; duplicate {
@@ -285,7 +285,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
285 }285 }
286 for range rowCount {286 for range rowCount {
287 id, ok := p.uint64()287 id, ok := p.uint64()
288- if !ok || id == 0 || id == ^uint64(0) {288+ if !ok || id == 0 || id >= uint64(^RowID(0)-1) {
289 return HistoryView{}, historyDecodeStats{}, false289 return HistoryView{}, historyDecodeStats{}, false
290 }290 }
291 rowID := RowID(id)291 rowID := RowID(id)
M pkg/vt/recovery_transcript.go +3 -3
@@ -27,9 +27,9 @@ func (s *Screen) RecoveryTranscriptSnapshot() RecoveryTranscriptSnapshot {
2727 buffers = []*buffer{s.alternate.buffer, s.buffer}
2828 }
2929
30- nextRowID := s.nextRowID + 1
31- if nextRowID == 0 {
32- nextRowID = ^RowID(0)
30+ nextRowID := s.nextRowID
31+ if nextRowID < ^RowID(0) {
32+ nextRowID++
3333 }
3434 snapshot := RecoveryTranscriptSnapshot{segments: make([]recoveryTranscriptSegment, 0, len(buffers)), nextRowID: nextRowID}
3535 for _, b := range buffers {
@@ -27,9 +27,9 @@ func (s *Screen) RecoveryTranscriptSnapshot() RecoveryTranscriptSnapshot {
27 buffers = []*buffer{s.alternate.buffer, s.buffer}27 buffers = []*buffer{s.alternate.buffer, s.buffer}
28 }28 }
29 29
30- nextRowID := s.nextRowID + 130+ nextRowID := s.nextRowID
31- if nextRowID == 0 {31+ if nextRowID < ^RowID(0) {
32- nextRowID = ^RowID(0)32+ nextRowID++
33 }33 }
34 snapshot := RecoveryTranscriptSnapshot{segments: make([]recoveryTranscriptSegment, 0, len(buffers)), nextRowID: nextRowID}34 snapshot := RecoveryTranscriptSnapshot{segments: make([]recoveryTranscriptSegment, 0, len(buffers)), nextRowID: nextRowID}
35 for _, b := range buffers {35 for _, b := range buffers {
M pkg/vt/row_id.go +1 -1
@@ -5,7 +5,7 @@ package vt
55 type RowID uint64
66
77 func (s *Screen) nextRowIDValue() RowID {
8- if s.nextRowID == ^RowID(0) {
8+ if s.nextRowID >= ^RowID(0)-1 {
99 panic("vt: row ID space exhausted")
1010 }
1111 s.nextRowID++
@@ -5,7 +5,7 @@ package vt
5 type RowID uint645 type RowID uint64
6 6
7 func (s *Screen) nextRowIDValue() RowID {7 func (s *Screen) nextRowIDValue() RowID {
8- if s.nextRowID == ^RowID(0) {8+ if s.nextRowID >= ^RowID(0)-1 {
9 panic("vt: row ID space exhausted")9 panic("vt: row ID space exhausted")
10 }10 }
11 s.nextRowID++11 s.nextRowID++
M pkg/vt/row_id_persistence_test.go +28 -0
@@ -24,6 +24,7 @@ func TestHistoryCodecRejectsMalformedRowIDsAndCounters(t *testing.T) {
2424 {name: "zero row ID", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[firstIDOffset:], 0) }},
2525 {name: "duplicate row ID", mutate: func(data []byte) { copy(data[secondIDOffset:secondIDOffset+8], data[firstIDOffset:firstIDOffset+8]) }},
2626 {name: "counter does not exceed IDs", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[9:17], 2) }},
27+ {name: "max counter", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[9:17], ^uint64(0)) }},
2728 } {
2829 t.Run(test.name, func(t *testing.T) {
2930 data := append([]byte(nil), encoded...)
@@ -36,6 +37,12 @@ func TestHistoryCodecRejectsMalformedRowIDsAndCounters(t *testing.T) {
3637 }
3738 }
3839
40+func TestHistoryAppendRejectsExhaustedRowIDCounter(t *testing.T) {
41+ history := NewHistory(HistoryConfig{MaxRows: 1, ChunkRows: 1})
42+ history.nextRowID = ^RowID(0)
43+ require.Error(t, history.Append(historyRow("row"), LineBound{End: 3}))
44+}
45+
3946 func TestHistoryCodecRejectsMissingRowIDsOnMarshal(t *testing.T) {
4047 _, err := MarshalHistory(HistoryView{
4148 chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("row")}, bounds: []LineBound{{End: 3}}}},
@@ -68,6 +75,27 @@ func TestRestoredScreenAllocatesAbovePersistedRowIDs(t *testing.T) {
6875 require.Greater(t, screen.RowID(0), before)
6976 }
7077
78+func TestHistoryRestoreRejectsDuplicateIDsAcrossEvictedHistoryAndTranscript(t *testing.T) {
79+ sealedView := HistoryView{
80+ chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("old")}, bounds: []LineBound{{End: 3}}, rowIDs: []RowID{7}}},
81+ rows: 1,
82+ nextRowID: 8,
83+ }
84+ sealed, err := MarshalHistoryChunk(sealedView.Chunk(0))
85+ require.NoError(t, err)
86+ tail, err := MarshalEmptyHistoryTail()
87+ require.NoError(t, err)
88+ transcript, err := MarshalHistory(HistoryView{
89+ chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("live")}, bounds: []LineBound{{End: 4}}, rowIDs: []RowID{7}}},
90+ rows: 1,
91+ nextRowID: 8,
92+ })
93+ require.NoError(t, err)
94+
95+ _, err = NewScreenWithRecoveryTranscript(4, 1, HistoryConfig{MaxRows: 1, ChunkRows: 1}, [][]byte{sealed}, tail, transcript)
96+ require.Error(t, err)
97+}
98+
7199 func TestHistoryRestoreRejectsDuplicateIDsAcrossBlobs(t *testing.T) {
72100 first := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1})
73101 second := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1})
@@ -24,6 +24,7 @@ func TestHistoryCodecRejectsMalformedRowIDsAndCounters(t *testing.T) {
24 {name: "zero row ID", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[firstIDOffset:], 0) }},24 {name: "zero row ID", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[firstIDOffset:], 0) }},
25 {name: "duplicate row ID", mutate: func(data []byte) { copy(data[secondIDOffset:secondIDOffset+8], data[firstIDOffset:firstIDOffset+8]) }},25 {name: "duplicate row ID", mutate: func(data []byte) { copy(data[secondIDOffset:secondIDOffset+8], data[firstIDOffset:firstIDOffset+8]) }},
26 {name: "counter does not exceed IDs", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[9:17], 2) }},26 {name: "counter does not exceed IDs", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[9:17], 2) }},
27+ {name: "max counter", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[9:17], ^uint64(0)) }},
27 } {28 } {
28 t.Run(test.name, func(t *testing.T) {29 t.Run(test.name, func(t *testing.T) {
29 data := append([]byte(nil), encoded...)30 data := append([]byte(nil), encoded...)
@@ -36,6 +37,12 @@ func TestHistoryCodecRejectsMalformedRowIDsAndCounters(t *testing.T) {
36 }37 }
37 }38 }
38 39
40+func TestHistoryAppendRejectsExhaustedRowIDCounter(t *testing.T) {
41+ history := NewHistory(HistoryConfig{MaxRows: 1, ChunkRows: 1})
42+ history.nextRowID = ^RowID(0)
43+ require.Error(t, history.Append(historyRow("row"), LineBound{End: 3}))
44+}
45+
39 func TestHistoryCodecRejectsMissingRowIDsOnMarshal(t *testing.T) {46 func TestHistoryCodecRejectsMissingRowIDsOnMarshal(t *testing.T) {
40 _, err := MarshalHistory(HistoryView{47 _, err := MarshalHistory(HistoryView{
41 chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("row")}, bounds: []LineBound{{End: 3}}}},48 chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("row")}, bounds: []LineBound{{End: 3}}}},
@@ -68,6 +75,27 @@ func TestRestoredScreenAllocatesAbovePersistedRowIDs(t *testing.T) {
68 require.Greater(t, screen.RowID(0), before)75 require.Greater(t, screen.RowID(0), before)
69 }76 }
70 77
78+func TestHistoryRestoreRejectsDuplicateIDsAcrossEvictedHistoryAndTranscript(t *testing.T) {
79+ sealedView := HistoryView{
80+ chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("old")}, bounds: []LineBound{{End: 3}}, rowIDs: []RowID{7}}},
81+ rows: 1,
82+ nextRowID: 8,
83+ }
84+ sealed, err := MarshalHistoryChunk(sealedView.Chunk(0))
85+ require.NoError(t, err)
86+ tail, err := MarshalEmptyHistoryTail()
87+ require.NoError(t, err)
88+ transcript, err := MarshalHistory(HistoryView{
89+ chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("live")}, bounds: []LineBound{{End: 4}}, rowIDs: []RowID{7}}},
90+ rows: 1,
91+ nextRowID: 8,
92+ })
93+ require.NoError(t, err)
94+
95+ _, err = NewScreenWithRecoveryTranscript(4, 1, HistoryConfig{MaxRows: 1, ChunkRows: 1}, [][]byte{sealed}, tail, transcript)
96+ require.Error(t, err)
97+}
98+
71 func TestHistoryRestoreRejectsDuplicateIDsAcrossBlobs(t *testing.T) {99 func TestHistoryRestoreRejectsDuplicateIDsAcrossBlobs(t *testing.T) {
72 first := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1})100 first := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1})
73 second := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1})101 second := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1})
M pkg/vt/row_id_test.go +21 -0
@@ -71,6 +71,27 @@ func TestScreenRowIDsRefreshOnClearResetAndAlternateClone(t *testing.T) {
7171 }
7272 }
7373
74+func TestResizeReflowCarriesSourceRowIDsIntoHistoryAndViewport(t *testing.T) {
75+ b := newBuffer(8, 3)
76+ copy(b.frame.Row(0), historyRow("abcdefgh"))
77+ copy(b.frame.Row(1), historyRow("ijkl"))
78+ copy(b.frame.Row(2), historyRow("mnop"))
79+ b.boundaries[0] = LineBound{End: 8, Soft: true}
80+ b.boundaries[1] = LineBound{End: 4}
81+ b.boundaries[2] = LineBound{End: 4}
82+ b.rowIDs = []RowID{11, 12, 13}
83+
84+ active := &bufferCursor{row: 2, col: 4}
85+ evicted, bounds, ids := b.resize(4, 2, active, nil)
86+
87+ if len(evicted) != 2 || bounds[0].End != 4 || ids[0] != 11 || ids[1] != 0 {
88+ t.Fatalf("reflow eviction = rows=%d bound=%v IDs=%v, want two rows with source ID 11 then a generated ID", len(evicted), bounds, ids)
89+ }
90+ if got, want := b.rowIDs, []RowID{0, 13}; got[1] != want[1] || len(got) != len(want) {
91+ t.Fatalf("reflow viewport IDs = %v, want generated continuation then source ID %d", got, want[1])
92+ }
93+}
94+
7495 func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) {
7596 h := NewHistory(HistoryConfig{MaxRows: 3, ChunkRows: 2})
7697 for i, text := range []string{"one", "two"} {
@@ -71,6 +71,27 @@ func TestScreenRowIDsRefreshOnClearResetAndAlternateClone(t *testing.T) {
71 }71 }
72 }72 }
73 73
74+func TestResizeReflowCarriesSourceRowIDsIntoHistoryAndViewport(t *testing.T) {
75+ b := newBuffer(8, 3)
76+ copy(b.frame.Row(0), historyRow("abcdefgh"))
77+ copy(b.frame.Row(1), historyRow("ijkl"))
78+ copy(b.frame.Row(2), historyRow("mnop"))
79+ b.boundaries[0] = LineBound{End: 8, Soft: true}
80+ b.boundaries[1] = LineBound{End: 4}
81+ b.boundaries[2] = LineBound{End: 4}
82+ b.rowIDs = []RowID{11, 12, 13}
83+
84+ active := &bufferCursor{row: 2, col: 4}
85+ evicted, bounds, ids := b.resize(4, 2, active, nil)
86+
87+ if len(evicted) != 2 || bounds[0].End != 4 || ids[0] != 11 || ids[1] != 0 {
88+ t.Fatalf("reflow eviction = rows=%d bound=%v IDs=%v, want two rows with source ID 11 then a generated ID", len(evicted), bounds, ids)
89+ }
90+ if got, want := b.rowIDs, []RowID{0, 13}; got[1] != want[1] || len(got) != len(want) {
91+ t.Fatalf("reflow viewport IDs = %v, want generated continuation then source ID %d", got, want[1])
92+ }
93+}
94+
74 func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) {95 func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) {
75 h := NewHistory(HistoryConfig{MaxRows: 3, ChunkRows: 2})96 h := NewHistory(HistoryConfig{MaxRows: 3, ChunkRows: 2})
76 for i, text := range []string{"one", "two"} {97 for i, text := range []string{"one", "two"} {
M pkg/vt/screen_snapshot.go +5 -1
@@ -43,11 +43,15 @@ func (s *Screen) Snapshot() ScreenSnapshot {
4343 }
4444 cursorStyle, cursorStyleSet := s.CursorStyle()
4545 mouseTracking, mouseSGR := s.MouseMode()
46+ nextRowID := s.nextRowID
47+ if nextRowID < ^RowID(0) {
48+ nextRowID++
49+ }
4650 return ScreenSnapshot{
4751 frame: s.Frame.Clone(),
4852 bounds: s.LineBounds(),
4953 rowIDs: s.RowIDs(),
50- nextRowID: s.nextRowID + 1,
54+ nextRowID: nextRowID,
5155 cursor: CursorSnapshot{
5256 Row: s.CursorRow(),
5357 Col: s.CursorCol(),
@@ -43,11 +43,15 @@ func (s *Screen) Snapshot() ScreenSnapshot {
43 }43 }
44 cursorStyle, cursorStyleSet := s.CursorStyle()44 cursorStyle, cursorStyleSet := s.CursorStyle()
45 mouseTracking, mouseSGR := s.MouseMode()45 mouseTracking, mouseSGR := s.MouseMode()
46+ nextRowID := s.nextRowID
47+ if nextRowID < ^RowID(0) {
48+ nextRowID++
49+ }
46 return ScreenSnapshot{50 return ScreenSnapshot{
47 frame: s.Frame.Clone(),51 frame: s.Frame.Clone(),
48 bounds: s.LineBounds(),52 bounds: s.LineBounds(),
49 rowIDs: s.RowIDs(),53 rowIDs: s.RowIDs(),
50- nextRowID: s.nextRowID + 1,54+ nextRowID: nextRowID,
51 cursor: CursorSnapshot{55 cursor: CursorSnapshot{
52 Row: s.CursorRow(),56 Row: s.CursorRow(),
53 Col: s.CursorCol(),57 Col: s.CursorCol(),
M pkg/vt/snapshot.go +54 -23
@@ -76,55 +76,85 @@ func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) {
7676 // HistoryFromBlobs restores history directly from sealed, oldest-first blobs
7777 // and the mandatory tail blob. It never feeds decoded rows through Append.
7878 func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*History, error) {
79- h := NewHistory(config)
79+ sealedViews, tailView, err := decodeRestoredHistoryBlobs(sealed, tail)
80+ if err != nil {
81+ return nil, err
82+ }
83+ return restoreHistoryViews(config, sealedViews, tailView), nil
84+}
85+
86+func decodeRestoredHistoryBlobs(sealed [][]byte, tail []byte, extra ...HistoryView) ([]HistoryView, HistoryView, error) {
8087 seen := make(map[RowID]struct{})
81- for _, blob := range sealed {
88+ sealedViews := make([]HistoryView, len(sealed))
89+ for i, blob := range sealed {
8290 view, err := UnmarshalHistory(blob)
8391 if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 || !validateRestoredHistoryView(view, seen) {
84- return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory)
92+ return nil, HistoryView{}, fmt.Errorf("restore sealed history: %w", errInvalidHistory)
8593 }
86- chunk := view.chunks[0]
94+ sealedViews[i] = view
95+ }
96+
97+ tailView, err := UnmarshalHistory(tail)
98+ if err != nil || len(tailView.chunks) > 1 || !validateRestoredHistoryView(tailView, seen) {
99+ return nil, HistoryView{}, fmt.Errorf("restore history tail: %w", errInvalidHistory)
100+ }
101+ for _, view := range extra {
102+ if !validateRestoredHistoryView(view, seen) {
103+ return nil, HistoryView{}, fmt.Errorf("restore recovery transcript: %w", errInvalidHistory)
104+ }
105+ }
106+ return sealedViews, tailView, nil
107+}
108+
109+func restoreHistoryViews(config HistoryConfig, sealed []HistoryView, tail HistoryView) *History {
110+ h := NewHistory(config)
111+ for _, view := range sealed {
87112 h.nextRowID = max(h.nextRowID, view.nextRowID)
88- h.evictUntil(len(chunk.rows), view.Cells())
113+ if h.maxRows == 0 {
114+ continue
115+ }
116+ chunk := view.chunks[0]
89117 h.chunks = append(h.chunks, chunk)
90118 h.rows += len(chunk.rows)
91119 h.cells += view.Cells()
120+ h.evict()
92121 }
93- view, err := UnmarshalHistory(tail)
94- if err != nil || len(view.chunks) > 1 || !validateRestoredHistoryView(view, seen) {
95- return nil, fmt.Errorf("restore history tail: %w", errInvalidHistory)
96- }
97- h.nextRowID = max(h.nextRowID, view.nextRowID)
98- if len(view.chunks) == 1 {
99- chunk := view.chunks[0]
100- h.evictUntil(len(chunk.rows), view.Cells())
101- h.tail = chunk.rows
102- h.tailBounds = append([]LineBound(nil), chunk.bounds...)
103- h.tailIDs = append([]RowID(nil), chunk.rowIDs...)
104- h.rows += len(h.tail)
105- h.cells += view.Cells()
122+ h.nextRowID = max(h.nextRowID, tail.nextRowID)
123+ if h.maxRows == 0 || len(tail.chunks) == 0 {
124+ return h
106125 }
126+ chunk := tail.chunks[0]
127+ h.tail = chunk.rows
128+ h.tailBounds = append([]LineBound(nil), chunk.bounds...)
129+ h.tailIDs = append([]RowID(nil), chunk.rowIDs...)
130+ h.rows += len(h.tail)
131+ h.cells += tail.Cells()
107132 h.evict()
108133 h.normalizeTail()
109- return h, nil
134+ return h
110135 }
111136
112137 func validateRestoredHistoryView(view HistoryView, seen map[RowID]struct{}) bool {
138+ if view.nextRowID == 0 || view.nextRowID == ^RowID(0) {
139+ return false
140+ }
141+ var maxID RowID
113142 for _, chunk := range view.chunks {
114143 if chunk == nil || len(chunk.rows) != len(chunk.rowIDs) || len(chunk.rows) != len(chunk.bounds) {
115144 return false
116145 }
117146 for _, id := range chunk.rowIDs {
118- if id == 0 || id == ^RowID(0) {
147+ if id == 0 || id >= ^RowID(0)-1 {
119148 return false
120149 }
121150 if _, duplicate := seen[id]; duplicate {
122151 return false
123152 }
124153 seen[id] = struct{}{}
154+ maxID = max(maxID, id)
125155 }
126156 }
127- return true
157+ return view.nextRowID > maxID
128158 }
129159
130160 // NewScreenWithRecoveryTranscript constructs a fresh blank screen whose
@@ -136,10 +166,11 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se
136166 return nil, fmt.Errorf("restore recovery transcript: %w", err)
137167 }
138168
139- history, err := HistoryFromBlobs(config, sealed, tail)
169+ sealedViews, tailView, err := decodeRestoredHistoryBlobs(sealed, tail, transcriptView)
140170 if err != nil {
141171 return nil, err
142172 }
173+ history := restoreHistoryViews(config, sealedViews, tailView)
143174 remainingRows := transcriptView.rows
144175 for _, chunk := range transcriptView.chunks {
145176 for i, row := range chunk.rows {
@@ -157,7 +188,7 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se
157188 screen := NewScreenWithHistory(width, height, config)
158189 screen.history = history
159190 counter := max(history.nextRowID, transcriptView.nextRowID, RowID(1))
160- if counter == ^RowID(0) {
191+ if counter == 0 || counter >= ^RowID(0) || uint64(height) > uint64(^RowID(0)-1-counter) {
161192 return nil, fmt.Errorf("restore recovery transcript: %w", errInvalidHistory)
162193 }
163194 screen.nextRowID = counter - 1
@@ -76,55 +76,85 @@ func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) {
76 // HistoryFromBlobs restores history directly from sealed, oldest-first blobs76 // HistoryFromBlobs restores history directly from sealed, oldest-first blobs
77 // and the mandatory tail blob. It never feeds decoded rows through Append.77 // and the mandatory tail blob. It never feeds decoded rows through Append.
78 func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*History, error) {78 func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*History, error) {
79- h := NewHistory(config)79+ sealedViews, tailView, err := decodeRestoredHistoryBlobs(sealed, tail)
80+ if err != nil {
81+ return nil, err
82+ }
83+ return restoreHistoryViews(config, sealedViews, tailView), nil
84+}
85+
86+func decodeRestoredHistoryBlobs(sealed [][]byte, tail []byte, extra ...HistoryView) ([]HistoryView, HistoryView, error) {
80 seen := make(map[RowID]struct{})87 seen := make(map[RowID]struct{})
81- for _, blob := range sealed {88+ sealedViews := make([]HistoryView, len(sealed))
89+ for i, blob := range sealed {
82 view, err := UnmarshalHistory(blob)90 view, err := UnmarshalHistory(blob)
83 if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 || !validateRestoredHistoryView(view, seen) {91 if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 || !validateRestoredHistoryView(view, seen) {
84- return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory)92+ return nil, HistoryView{}, fmt.Errorf("restore sealed history: %w", errInvalidHistory)
85 }93 }
86- chunk := view.chunks[0]94+ sealedViews[i] = view
95+ }
96+
97+ tailView, err := UnmarshalHistory(tail)
98+ if err != nil || len(tailView.chunks) > 1 || !validateRestoredHistoryView(tailView, seen) {
99+ return nil, HistoryView{}, fmt.Errorf("restore history tail: %w", errInvalidHistory)
100+ }
101+ for _, view := range extra {
102+ if !validateRestoredHistoryView(view, seen) {
103+ return nil, HistoryView{}, fmt.Errorf("restore recovery transcript: %w", errInvalidHistory)
104+ }
105+ }
106+ return sealedViews, tailView, nil
107+}
108+
109+func restoreHistoryViews(config HistoryConfig, sealed []HistoryView, tail HistoryView) *History {
110+ h := NewHistory(config)
111+ for _, view := range sealed {
87 h.nextRowID = max(h.nextRowID, view.nextRowID)112 h.nextRowID = max(h.nextRowID, view.nextRowID)
88- h.evictUntil(len(chunk.rows), view.Cells())113+ if h.maxRows == 0 {
114+ continue
115+ }
116+ chunk := view.chunks[0]
89 h.chunks = append(h.chunks, chunk)117 h.chunks = append(h.chunks, chunk)
90 h.rows += len(chunk.rows)118 h.rows += len(chunk.rows)
91 h.cells += view.Cells()119 h.cells += view.Cells()
120+ h.evict()
92 }121 }
93- view, err := UnmarshalHistory(tail)122+ h.nextRowID = max(h.nextRowID, tail.nextRowID)
94- if err != nil || len(view.chunks) > 1 || !validateRestoredHistoryView(view, seen) {123+ if h.maxRows == 0 || len(tail.chunks) == 0 {
95- return nil, fmt.Errorf("restore history tail: %w", errInvalidHistory)124+ return h
96- }
97- h.nextRowID = max(h.nextRowID, view.nextRowID)
98- if len(view.chunks) == 1 {
99- chunk := view.chunks[0]
100- h.evictUntil(len(chunk.rows), view.Cells())
101- h.tail = chunk.rows
102- h.tailBounds = append([]LineBound(nil), chunk.bounds...)
103- h.tailIDs = append([]RowID(nil), chunk.rowIDs...)
104- h.rows += len(h.tail)
105- h.cells += view.Cells()
106 }125 }
126+ chunk := tail.chunks[0]
127+ h.tail = chunk.rows
128+ h.tailBounds = append([]LineBound(nil), chunk.bounds...)
129+ h.tailIDs = append([]RowID(nil), chunk.rowIDs...)
130+ h.rows += len(h.tail)
131+ h.cells += tail.Cells()
107 h.evict()132 h.evict()
108 h.normalizeTail()133 h.normalizeTail()
109- return h, nil134+ return h
110 }135 }
111 136
112 func validateRestoredHistoryView(view HistoryView, seen map[RowID]struct{}) bool {137 func validateRestoredHistoryView(view HistoryView, seen map[RowID]struct{}) bool {
138+ if view.nextRowID == 0 || view.nextRowID == ^RowID(0) {
139+ return false
140+ }
141+ var maxID RowID
113 for _, chunk := range view.chunks {142 for _, chunk := range view.chunks {
114 if chunk == nil || len(chunk.rows) != len(chunk.rowIDs) || len(chunk.rows) != len(chunk.bounds) {143 if chunk == nil || len(chunk.rows) != len(chunk.rowIDs) || len(chunk.rows) != len(chunk.bounds) {
115 return false144 return false
116 }145 }
117 for _, id := range chunk.rowIDs {146 for _, id := range chunk.rowIDs {
118- if id == 0 || id == ^RowID(0) {147+ if id == 0 || id >= ^RowID(0)-1 {
119 return false148 return false
120 }149 }
121 if _, duplicate := seen[id]; duplicate {150 if _, duplicate := seen[id]; duplicate {
122 return false151 return false
123 }152 }
124 seen[id] = struct{}{}153 seen[id] = struct{}{}
154+ maxID = max(maxID, id)
125 }155 }
126 }156 }
127- return true157+ return view.nextRowID > maxID
128 }158 }
129 159
130 // NewScreenWithRecoveryTranscript constructs a fresh blank screen whose160 // NewScreenWithRecoveryTranscript constructs a fresh blank screen whose
@@ -136,10 +166,11 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se
136 return nil, fmt.Errorf("restore recovery transcript: %w", err)166 return nil, fmt.Errorf("restore recovery transcript: %w", err)
137 }167 }
138 168
139- history, err := HistoryFromBlobs(config, sealed, tail)169+ sealedViews, tailView, err := decodeRestoredHistoryBlobs(sealed, tail, transcriptView)
140 if err != nil {170 if err != nil {
141 return nil, err171 return nil, err
142 }172 }
173+ history := restoreHistoryViews(config, sealedViews, tailView)
143 remainingRows := transcriptView.rows174 remainingRows := transcriptView.rows
144 for _, chunk := range transcriptView.chunks {175 for _, chunk := range transcriptView.chunks {
145 for i, row := range chunk.rows {176 for i, row := range chunk.rows {
@@ -157,7 +188,7 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se
157 screen := NewScreenWithHistory(width, height, config)188 screen := NewScreenWithHistory(width, height, config)
158 screen.history = history189 screen.history = history
159 counter := max(history.nextRowID, transcriptView.nextRowID, RowID(1))190 counter := max(history.nextRowID, transcriptView.nextRowID, RowID(1))
160- if counter == ^RowID(0) {191+ if counter == 0 || counter >= ^RowID(0) || uint64(height) > uint64(^RowID(0)-1-counter) {
161 return nil, fmt.Errorf("restore recovery transcript: %w", errInvalidHistory)192 return nil, fmt.Errorf("restore recovery transcript: %w", errInvalidHistory)
162 }193 }
163 screen.nextRowID = counter - 1194 screen.nextRowID = counter - 1
M pkg/vt/snapshot_test.go +5 -0
@@ -34,6 +34,11 @@ func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) {
3434 if got, want := view.Cells(), 12; got != want {
3535 t.Fatalf("snapshot cells = %d, want %d", got, want)
3636 }
37+ wantID := view.Tail().RowID(0)
38+ history.tailIDs[0] = 99
39+ if got := view.Tail().RowID(0); got != wantID {
40+ t.Fatalf("snapshot tail ID = %d after live mutation, want %d", got, wantID)
41+ }
3742
3843 tail, err := MarshalHistoryTail(view)
3944 require.NoError(t, err)
@@ -34,6 +34,11 @@ func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) {
34 if got, want := view.Cells(), 12; got != want {34 if got, want := view.Cells(), 12; got != want {
35 t.Fatalf("snapshot cells = %d, want %d", got, want)35 t.Fatalf("snapshot cells = %d, want %d", got, want)
36 }36 }
37+ wantID := view.Tail().RowID(0)
38+ history.tailIDs[0] = 99
39+ if got := view.Tail().RowID(0); got != wantID {
40+ t.Fatalf("snapshot tail ID = %d after live mutation, want %d", got, wantID)
41+ }
37 42
38 tail, err := MarshalHistoryTail(view)43 tail, err := MarshalHistoryTail(view)
39 require.NoError(t, err)44 require.NoError(t, err)

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