detached 738b9eb

Merge pull request #172 from bnema/feat/stable-terminal-row-identities

feat(session): preserve stable terminal row identities
commit
738b9eb3a4226d94bcf1933f0eb69a9e2a01c6c8
author
bnema · · 2026-08-03 19:49 UTC
committer
GitHub · · 2026-08-03 19:49 UTC
merge of
cc10813 + af6e54f · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
33 files · +1204 -184
M internal/usecase/copy/document.go +3 -0
@@ -5,6 +5,7 @@ import (
55 "unicode"
66
77 "github.com/bnema/vev/pkg/renderer"
8+ "github.com/bnema/vev/pkg/vt"
89 )
910
1011 // Pos identifies a terminal display cell in a document. Valid positions are
@@ -40,6 +41,8 @@ func (d *Document) Len() int { return d.snapshot.Len() }
4041 func (d *Document) Width() int { return d.snapshot.Width }
4142 func (d *Document) Height() int { return d.snapshot.Height }
4243 func (d *Document) Row(row int) []renderer.Cell { return d.snapshot.Row(row) }
44+func (d *Document) RowID(row int) vt.RowID { return d.snapshot.RowID(row) }
45+func (d *Document) FindRowID(id vt.RowID) int { return d.snapshot.FindRowID(id) }
4346
4447 // Normalize returns pos as a valid glyph head. Empty physical rows have the
4548 // stable logical position {row, 0}, which is useful to vertical navigation.
@@ -5,6 +5,7 @@ import (
5 "unicode"5 "unicode"
6 6
7 "github.com/bnema/vev/pkg/renderer"7 "github.com/bnema/vev/pkg/renderer"
8+ "github.com/bnema/vev/pkg/vt"
8 )9 )
9 10
10 // Pos identifies a terminal display cell in a document. Valid positions are11 // Pos identifies a terminal display cell in a document. Valid positions are
@@ -40,6 +41,8 @@ func (d *Document) Len() int { return d.snapshot.Len() }
40 func (d *Document) Width() int { return d.snapshot.Width }41 func (d *Document) Width() int { return d.snapshot.Width }
41 func (d *Document) Height() int { return d.snapshot.Height }42 func (d *Document) Height() int { return d.snapshot.Height }
42 func (d *Document) Row(row int) []renderer.Cell { return d.snapshot.Row(row) }43 func (d *Document) Row(row int) []renderer.Cell { return d.snapshot.Row(row) }
44+func (d *Document) RowID(row int) vt.RowID { return d.snapshot.RowID(row) }
45+func (d *Document) FindRowID(id vt.RowID) int { return d.snapshot.FindRowID(id) }
43 46
44 // Normalize returns pos as a valid glyph head. Empty physical rows have the47 // Normalize returns pos as a valid glyph head. Empty physical rows have the
45 // stable logical position {row, 0}, which is useful to vertical navigation.48 // stable logical position {row, 0}, which is useful to vertical navigation.
M internal/usecase/copy/mode.go +66 -3
@@ -14,16 +14,33 @@ import (
1414 const OSC52MaxPayloadBytes = 75_000
1515
1616 // Snapshot is the immutable scrollback document: sealed history plus a cloned
17-// screen. screenBounds is parallel to screen rows, exactly as the history view's
18-// own bounds are parallel to its chunk rows.
17+// screen. screenBounds and screenRowIDs are parallel to screen rows, exactly as
18+// the history view's own metadata is parallel to its chunk rows.
1919 type Snapshot struct {
2020 history vt.HistoryView
2121 screen renderer.Frame
2222 screenBounds []vt.LineBound
23+ screenRowIDs []vt.RowID
2324 Width, Height int
2425 }
2526
26-func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound) Snapshot {
27+func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, rowIDs []vt.RowID) Snapshot {
28+ return newSnapshot(historySource, screen, bounds, append([]vt.RowID(nil), rowIDs...))
29+}
30+
31+// NewSnapshotFromScreen captures a screen while its owner holds the Screen
32+// lock. Screen.RowIDs returns the one owned copy required for immutable
33+// snapshot state; newSnapshot takes that slice directly without duplicating
34+// it. The live screen's rowIDs cannot be transferred or shared because later
35+// scroll, resize, and clear mutations update that storage.
36+func NewSnapshotFromScreen(historySource *vt.History, screen *vt.Screen) Snapshot {
37+ if screen == nil {
38+ return NewSnapshot(historySource, renderer.Frame{}, nil, nil)
39+ }
40+ return newSnapshot(historySource, screen.Frame, screen.LineBounds(), screen.RowIDs())
41+}
42+
43+func newSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, screenRowIDs []vt.RowID) Snapshot {
2744 var history vt.HistoryView
2845 if historySource != nil {
2946 history = historySource.SealAndView()
@@ -32,6 +49,7 @@ func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.L
3249 history: history,
3350 screen: screen.Clone(),
3451 screenBounds: append([]vt.LineBound(nil), bounds...),
52+ screenRowIDs: screenRowIDs,
3553 Width: screen.Width,
3654 Height: screen.Height,
3755 }
@@ -104,6 +122,51 @@ func (s Snapshot) Bound(i int) vt.LineBound {
104122 }
105123 return s.screenBounds[i]
106124 }
125+
126+// RowIDs returns an owned copy of physical row identities in document order.
127+func (s Snapshot) RowIDs() []vt.RowID {
128+ ids := make([]vt.RowID, s.Len())
129+ for i := range ids {
130+ ids[i] = s.RowID(i)
131+ }
132+ return ids
133+}
134+
135+// RowID returns the identity of document row i, or zero when it is unavailable.
136+func (s Snapshot) RowID(i int) vt.RowID {
137+ if i < 0 {
138+ return 0
139+ }
140+ if i < s.history.Len() {
141+ return s.history.RowID(i)
142+ }
143+ i -= s.history.Len()
144+ if i >= s.screen.Height || i >= len(s.screenRowIDs) {
145+ return 0
146+ }
147+ return s.screenRowIDs[i]
148+}
149+
150+// FindRowID returns the document row containing id, or -1 when it is absent.
151+func (s Snapshot) FindRowID(id vt.RowID) int {
152+ if id == 0 {
153+ return -1
154+ }
155+ if row := s.history.FindRowID(id); row >= 0 {
156+ return row
157+ }
158+ offset := s.history.Len()
159+ for i, candidate := range s.screenRowIDs {
160+ if i >= s.screen.Height {
161+ break
162+ }
163+ if candidate == id {
164+ return offset + i
165+ }
166+ }
167+ return -1
168+}
169+
107170 func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) {
108171 i := 0
109172 stopped := false
@@ -14,16 +14,33 @@ import (
14 const OSC52MaxPayloadBytes = 75_00014 const OSC52MaxPayloadBytes = 75_000
15 15
16 // Snapshot is the immutable scrollback document: sealed history plus a cloned16 // Snapshot is the immutable scrollback document: sealed history plus a cloned
17-// screen. screenBounds is parallel to screen rows, exactly as the history view's17+// screen. screenBounds and screenRowIDs are parallel to screen rows, exactly as
18-// own bounds are parallel to its chunk rows.18+// the history view's own metadata is parallel to its chunk rows.
19 type Snapshot struct {19 type Snapshot struct {
20 history vt.HistoryView20 history vt.HistoryView
21 screen renderer.Frame21 screen renderer.Frame
22 screenBounds []vt.LineBound22 screenBounds []vt.LineBound
23+ screenRowIDs []vt.RowID
23 Width, Height int24 Width, Height int
24 }25 }
25 26
26-func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound) Snapshot {27+func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, rowIDs []vt.RowID) Snapshot {
28+ return newSnapshot(historySource, screen, bounds, append([]vt.RowID(nil), rowIDs...))
29+}
30+
31+// NewSnapshotFromScreen captures a screen while its owner holds the Screen
32+// lock. Screen.RowIDs returns the one owned copy required for immutable
33+// snapshot state; newSnapshot takes that slice directly without duplicating
34+// it. The live screen's rowIDs cannot be transferred or shared because later
35+// scroll, resize, and clear mutations update that storage.
36+func NewSnapshotFromScreen(historySource *vt.History, screen *vt.Screen) Snapshot {
37+ if screen == nil {
38+ return NewSnapshot(historySource, renderer.Frame{}, nil, nil)
39+ }
40+ return newSnapshot(historySource, screen.Frame, screen.LineBounds(), screen.RowIDs())
41+}
42+
43+func newSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, screenRowIDs []vt.RowID) Snapshot {
27 var history vt.HistoryView44 var history vt.HistoryView
28 if historySource != nil {45 if historySource != nil {
29 history = historySource.SealAndView()46 history = historySource.SealAndView()
@@ -32,6 +49,7 @@ func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.L
32 history: history,49 history: history,
33 screen: screen.Clone(),50 screen: screen.Clone(),
34 screenBounds: append([]vt.LineBound(nil), bounds...),51 screenBounds: append([]vt.LineBound(nil), bounds...),
52+ screenRowIDs: screenRowIDs,
35 Width: screen.Width,53 Width: screen.Width,
36 Height: screen.Height,54 Height: screen.Height,
37 }55 }
@@ -104,6 +122,51 @@ func (s Snapshot) Bound(i int) vt.LineBound {
104 }122 }
105 return s.screenBounds[i]123 return s.screenBounds[i]
106 }124 }
125+
126+// RowIDs returns an owned copy of physical row identities in document order.
127+func (s Snapshot) RowIDs() []vt.RowID {
128+ ids := make([]vt.RowID, s.Len())
129+ for i := range ids {
130+ ids[i] = s.RowID(i)
131+ }
132+ return ids
133+}
134+
135+// RowID returns the identity of document row i, or zero when it is unavailable.
136+func (s Snapshot) RowID(i int) vt.RowID {
137+ if i < 0 {
138+ return 0
139+ }
140+ if i < s.history.Len() {
141+ return s.history.RowID(i)
142+ }
143+ i -= s.history.Len()
144+ if i >= s.screen.Height || i >= len(s.screenRowIDs) {
145+ return 0
146+ }
147+ return s.screenRowIDs[i]
148+}
149+
150+// FindRowID returns the document row containing id, or -1 when it is absent.
151+func (s Snapshot) FindRowID(id vt.RowID) int {
152+ if id == 0 {
153+ return -1
154+ }
155+ if row := s.history.FindRowID(id); row >= 0 {
156+ return row
157+ }
158+ offset := s.history.Len()
159+ for i, candidate := range s.screenRowIDs {
160+ if i >= s.screen.Height {
161+ break
162+ }
163+ if candidate == id {
164+ return offset + i
165+ }
166+ }
167+ return -1
168+}
169+
107 func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) {170 func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) {
108 i := 0171 i := 0
109 stopped := false172 stopped := false
M internal/usecase/copy/mode_test.go +27 -4
@@ -42,6 +42,29 @@ func TestNewSnapshotFromRowsPreservesWideRows(t *testing.T) {
4242 }
4343 }
4444
45+func TestSnapshotCarriesImmutableRowIDsAndDocumentsResolveBookmarks(t *testing.T) {
46+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024})
47+ require.NoError(t, history.AppendWithID(row("hist"), vt.LineBound{End: 4}, vt.RowID(11)))
48+ require.NoError(t, history.AppendWithID(row("tail"), vt.LineBound{End: 4}, vt.RowID(12)))
49+ screen := renderer.NewFrame(4, 2)
50+ copy(screen.Row(0), row("live"))
51+ copy(screen.Row(1), row("more"))
52+ rowIDs := []vt.RowID{21, 22}
53+
54+ snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 4}}, rowIDs)
55+ rowIDs[0] = 99
56+ doc := NewDocument(snapshot, "")
57+
58+ require.Equal(t, []vt.RowID{11, 12, 21, 22}, snapshot.RowIDs())
59+ ids := snapshot.RowIDs()
60+ ids[0] = 100
61+ require.Equal(t, vt.RowID(11), snapshot.RowID(0))
62+ require.Equal(t, vt.RowID(21), doc.RowID(2))
63+ require.Equal(t, 2, doc.FindRowID(vt.RowID(21)))
64+ require.Equal(t, 3, doc.FindRowID(vt.RowID(22)))
65+ require.Equal(t, -1, doc.FindRowID(vt.RowID(99)))
66+}
67+
4568 func TestSnapshotBoundDispatchesLikeRow(t *testing.T) {
4669 row := func(s string) []renderer.Cell {
4770 cells := make([]renderer.Cell, 0, len(s))
@@ -58,7 +81,7 @@ func TestSnapshotBoundDispatchesLikeRow(t *testing.T) {
5881 screen := renderer.NewFrame(4, 2)
5982 copy(screen.Row(0), row("live"))
6083
61- snap := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 0, Soft: true}})
84+ snap := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 0, Soft: true}}, nil)
6285
6386 tests := []struct {
6487 name string
@@ -232,7 +255,7 @@ func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) {
232255 require.NoError(t, history.Append(unmatched, vt.LineBound{End: len(unmatched)}))
233256 }
234257 require.NoError(t, history.Append(target, vt.LineBound{End: len(target)}))
235- snapshot := NewSnapshot(history, renderer.NewFrame(16, 1), nil)
258+ snapshot := NewSnapshot(history, renderer.NewFrame(16, 1), nil, nil)
236259 view := history.SealAndView()
237260 require.Same(t, view.Chunk(0), snapshot.history.Chunk(0))
238261
@@ -345,7 +368,7 @@ func TestSelectedTextJoinsAcrossTheHistoryScreenBoundary(t *testing.T) {
345368 copy(screen.Row(0), row("ef", width))
346369 copy(screen.Row(1), row("gh", width))
347370
348- snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}})
371+ snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}}, nil)
349372 doc := NewDocument(snapshot, "")
350373
351374 ranges := []CellRange{
@@ -414,7 +437,7 @@ func TestSelectedTextJoinsRowsWrappedByTheVT(t *testing.T) {
414437 screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64, MaxCells: 4096})
415438 screen.Write([]byte(tc.input))
416439
417- snapshot := NewSnapshot(screen.History(), screen.Frame, screen.LineBounds())
440+ snapshot := NewSnapshot(screen.History(), screen.Frame, screen.LineBounds(), nil)
418441 require.Equal(t, tc.wantHistoryLen, snapshot.history.Len(), "history/screen seam moved")
419442 doc := NewDocument(snapshot, domain.DefaultWordSeparators)
420443
@@ -42,6 +42,29 @@ func TestNewSnapshotFromRowsPreservesWideRows(t *testing.T) {
42 }42 }
43 }43 }
44 44
45+func TestSnapshotCarriesImmutableRowIDsAndDocumentsResolveBookmarks(t *testing.T) {
46+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024})
47+ require.NoError(t, history.AppendWithID(row("hist"), vt.LineBound{End: 4}, vt.RowID(11)))
48+ require.NoError(t, history.AppendWithID(row("tail"), vt.LineBound{End: 4}, vt.RowID(12)))
49+ screen := renderer.NewFrame(4, 2)
50+ copy(screen.Row(0), row("live"))
51+ copy(screen.Row(1), row("more"))
52+ rowIDs := []vt.RowID{21, 22}
53+
54+ snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 4}}, rowIDs)
55+ rowIDs[0] = 99
56+ doc := NewDocument(snapshot, "")
57+
58+ require.Equal(t, []vt.RowID{11, 12, 21, 22}, snapshot.RowIDs())
59+ ids := snapshot.RowIDs()
60+ ids[0] = 100
61+ require.Equal(t, vt.RowID(11), snapshot.RowID(0))
62+ require.Equal(t, vt.RowID(21), doc.RowID(2))
63+ require.Equal(t, 2, doc.FindRowID(vt.RowID(21)))
64+ require.Equal(t, 3, doc.FindRowID(vt.RowID(22)))
65+ require.Equal(t, -1, doc.FindRowID(vt.RowID(99)))
66+}
67+
45 func TestSnapshotBoundDispatchesLikeRow(t *testing.T) {68 func TestSnapshotBoundDispatchesLikeRow(t *testing.T) {
46 row := func(s string) []renderer.Cell {69 row := func(s string) []renderer.Cell {
47 cells := make([]renderer.Cell, 0, len(s))70 cells := make([]renderer.Cell, 0, len(s))
@@ -58,7 +81,7 @@ func TestSnapshotBoundDispatchesLikeRow(t *testing.T) {
58 screen := renderer.NewFrame(4, 2)81 screen := renderer.NewFrame(4, 2)
59 copy(screen.Row(0), row("live"))82 copy(screen.Row(0), row("live"))
60 83
61- snap := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 0, Soft: true}})84+ snap := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 0, Soft: true}}, nil)
62 85
63 tests := []struct {86 tests := []struct {
64 name string87 name string
@@ -232,7 +255,7 @@ func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) {
232 require.NoError(t, history.Append(unmatched, vt.LineBound{End: len(unmatched)}))255 require.NoError(t, history.Append(unmatched, vt.LineBound{End: len(unmatched)}))
233 }256 }
234 require.NoError(t, history.Append(target, vt.LineBound{End: len(target)}))257 require.NoError(t, history.Append(target, vt.LineBound{End: len(target)}))
235- snapshot := NewSnapshot(history, renderer.NewFrame(16, 1), nil)258+ snapshot := NewSnapshot(history, renderer.NewFrame(16, 1), nil, nil)
236 view := history.SealAndView()259 view := history.SealAndView()
237 require.Same(t, view.Chunk(0), snapshot.history.Chunk(0))260 require.Same(t, view.Chunk(0), snapshot.history.Chunk(0))
238 261
@@ -345,7 +368,7 @@ func TestSelectedTextJoinsAcrossTheHistoryScreenBoundary(t *testing.T) {
345 copy(screen.Row(0), row("ef", width))368 copy(screen.Row(0), row("ef", width))
346 copy(screen.Row(1), row("gh", width))369 copy(screen.Row(1), row("gh", width))
347 370
348- snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}})371+ snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}}, nil)
349 doc := NewDocument(snapshot, "")372 doc := NewDocument(snapshot, "")
350 373
351 ranges := []CellRange{374 ranges := []CellRange{
@@ -414,7 +437,7 @@ func TestSelectedTextJoinsRowsWrappedByTheVT(t *testing.T) {
414 screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64, MaxCells: 4096})437 screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64, MaxCells: 4096})
415 screen.Write([]byte(tc.input))438 screen.Write([]byte(tc.input))
416 439
417- snapshot := NewSnapshot(screen.History(), screen.Frame, screen.LineBounds())440+ snapshot := NewSnapshot(screen.History(), screen.Frame, screen.LineBounds(), nil)
418 require.Equal(t, tc.wantHistoryLen, snapshot.history.Len(), "history/screen seam moved")441 require.Equal(t, tc.wantHistoryLen, snapshot.history.Len(), "history/screen seam moved")
419 doc := NewDocument(snapshot, domain.DefaultWordSeparators)442 doc := NewDocument(snapshot, domain.DefaultWordSeparators)
420 443
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())
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())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())
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())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 internal/usecase/daemon/copymode_lifecycle_test.go +3 -3
@@ -101,7 +101,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
101101 sess.client = ac
102102 ac.setSession(sess)
103103 closing.mu.Lock()
104- document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds()), domain.DefaultWordSeparators)
104+ document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds(), nil), domain.DefaultWordSeparators)
105105 closing.mu.Unlock()
106106 ac.overlays.copyMu.Lock()
107107 ac.overlays.copyPane = closing
@@ -146,7 +146,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
146146 tb := sess.activeTab()
147147 p := tb.focusedPane()
148148 p.mu.Lock()
149- document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators)
149+ document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
150150 p.mu.Unlock()
151151
152152 // Pause after candidate publication, then make the captured target stale.
@@ -370,7 +370,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.
370370 tb := sess.activeTab()
371371 p := tb.focusedPane()
372372 p.mu.Lock()
373- document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators)
373+ document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
374374 p.mu.Unlock()
375375 tc.invalidate(sess, tb, p)
376376
@@ -101,7 +101,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
101 sess.client = ac101 sess.client = ac
102 ac.setSession(sess)102 ac.setSession(sess)
103 closing.mu.Lock()103 closing.mu.Lock()
104- document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds()), domain.DefaultWordSeparators)104+ document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds(), nil), domain.DefaultWordSeparators)
105 closing.mu.Unlock()105 closing.mu.Unlock()
106 ac.overlays.copyMu.Lock()106 ac.overlays.copyMu.Lock()
107 ac.overlays.copyPane = closing107 ac.overlays.copyPane = closing
@@ -146,7 +146,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
146 tb := sess.activeTab()146 tb := sess.activeTab()
147 p := tb.focusedPane()147 p := tb.focusedPane()
148 p.mu.Lock()148 p.mu.Lock()
149- document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators)149+ document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
150 p.mu.Unlock()150 p.mu.Unlock()
151 151
152 // Pause after candidate publication, then make the captured target stale.152 // Pause after candidate publication, then make the captured target stale.
@@ -370,7 +370,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.
370 tb := sess.activeTab()370 tb := sess.activeTab()
371 p := tb.focusedPane()371 p := tb.focusedPane()
372 p.mu.Lock()372 p.mu.Lock()
373- document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators)373+ document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
374 p.mu.Unlock()374 p.mu.Unlock()
375 tc.invalidate(sess, tb, p)375 tc.invalidate(sess, tb, p)
376 376
M internal/usecase/daemon/copymode_mouse_test.go +1 -1
@@ -350,7 +350,7 @@ func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) {
350350 {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) {
351351 p := sess.activeTab().focusedPane()
352352 p.mu.Lock()
353- doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators)
353+ doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
354354 p.mu.Unlock()
355355 require.True(t, d.publishCopyMode(sess, ac, sess.activeTab(), p, doc, nil, nil))
356356 }},
@@ -350,7 +350,7 @@ func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) {
350 {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) {350 {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) {
351 p := sess.activeTab().focusedPane()351 p := sess.activeTab().focusedPane()
352 p.mu.Lock()352 p.mu.Lock()
353- doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators)353+ doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil), domain.DefaultWordSeparators)
354 p.mu.Unlock()354 p.mu.Unlock()
355 require.True(t, d.publishCopyMode(sess, ac, sess.activeTab(), p, doc, nil, nil))355 require.True(t, d.publishCopyMode(sess, ac, sess.activeTab(), p, doc, nil, nil))
356 }},356 }},
M internal/usecase/daemon/copymode_test.go +24 -5
@@ -105,6 +105,25 @@ func TestCopySearchModalGeometry(t *testing.T) {
105105 require.Equal(t, ui.Margins{Bottom: 1}, copySearchModal.Margins)
106106 }
107107
108+func TestCopyModeDocumentCarriesPaneRowIDs(t *testing.T) {
109+ p, _ := newBlockingPTY(t)
110+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
111+ pane := sess.activeTab().focusedPane()
112+ appendHistoryRow(t, pane.history, testRow("history"))
113+ pane.screen.Write([]byte("live"))
114+ historyID := pane.history.View().RowID(0)
115+ liveID := pane.screen.RowID(0)
116+ require.NotEqual(t, historyID, liveID)
117+
118+ d.enterCopyMode(sess, ac)
119+ awaitFrame(t, sends, ports.MsgOutput)
120+ doc := ac.overlays.copyDocument
121+
122+ require.Equal(t, 0, doc.FindRowID(historyID))
123+ require.Equal(t, pane.history.Len(), doc.FindRowID(liveID))
124+ require.Equal(t, liveID, doc.RowID(pane.history.Len()))
125+}
126+
108127 func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
109128 p, release := newBlockingPTY(t)
110129 defer release()
@@ -112,7 +131,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
112131 tb := sess.activeTab()
113132 pane := tb.focusedPane()
114133 pane.mu.Lock()
115- snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds())
134+ snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds(), nil)
116135 pane.mu.Unlock()
117136 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
118137
@@ -146,7 +165,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
146165 tb := sess.activeTab()
147166 tb.focusedPane().screen = vt.NewScreen(12, 3)
148167 tb.focusedPane().screen.Write([]byte("live"))
149- snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame, tb.focusedPane().screen.LineBounds())
168+ snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame, tb.focusedPane().screen.LineBounds(), nil)
150169 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
151170
152171 bars := barState{status: sess.statusSegments(true)}
@@ -434,7 +453,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
434453 }
435454 p.mu.Lock()
436455 defer p.mu.Unlock()
437- return scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()).Len() >= 12
456+ return scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil).Len() >= 12
438457 }, 2*time.Second, 5*time.Millisecond)
439458
440459 sess := firstSession(d)
@@ -784,7 +803,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
784803 }
785804 fp.screen.Write([]byte("live"))
786805 fp.mu.Lock()
787- total := scopy.NewSnapshot(fp.history, fp.screen.Frame, fp.screen.LineBounds()).Len()
806+ total := scopy.NewSnapshot(fp.history, fp.screen.Frame, fp.screen.LineBounds(), nil).Len()
788807 fp.mu.Unlock()
789808
790809 d.enterCopyMode(sess, ac)
@@ -895,7 +914,7 @@ func TestComposeCopyClientFrameStylesStatusContentAndSurround(t *testing.T) {
895914 base := renderer.NewFrame(20, 8)
896915 pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2})
897916 pane.screen.Write([]byte("copy"))
898- mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds()), domain.DefaultWordSeparators))
917+ mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds(), nil), domain.DefaultWordSeparators))
899918 if tt.selectMode {
900919 mode.ToggleLineSelection()
901920 }
@@ -105,6 +105,25 @@ func TestCopySearchModalGeometry(t *testing.T) {
105 require.Equal(t, ui.Margins{Bottom: 1}, copySearchModal.Margins)105 require.Equal(t, ui.Margins{Bottom: 1}, copySearchModal.Margins)
106 }106 }
107 107
108+func TestCopyModeDocumentCarriesPaneRowIDs(t *testing.T) {
109+ p, _ := newBlockingPTY(t)
110+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
111+ pane := sess.activeTab().focusedPane()
112+ appendHistoryRow(t, pane.history, testRow("history"))
113+ pane.screen.Write([]byte("live"))
114+ historyID := pane.history.View().RowID(0)
115+ liveID := pane.screen.RowID(0)
116+ require.NotEqual(t, historyID, liveID)
117+
118+ d.enterCopyMode(sess, ac)
119+ awaitFrame(t, sends, ports.MsgOutput)
120+ doc := ac.overlays.copyDocument
121+
122+ require.Equal(t, 0, doc.FindRowID(historyID))
123+ require.Equal(t, pane.history.Len(), doc.FindRowID(liveID))
124+ require.Equal(t, liveID, doc.RowID(pane.history.Len()))
125+}
126+
108 func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {127 func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
109 p, release := newBlockingPTY(t)128 p, release := newBlockingPTY(t)
110 defer release()129 defer release()
@@ -112,7 +131,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
112 tb := sess.activeTab()131 tb := sess.activeTab()
113 pane := tb.focusedPane()132 pane := tb.focusedPane()
114 pane.mu.Lock()133 pane.mu.Lock()
115- snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds())134+ snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds(), nil)
116 pane.mu.Unlock()135 pane.mu.Unlock()
117 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))136 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
118 137
@@ -146,7 +165,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
146 tb := sess.activeTab()165 tb := sess.activeTab()
147 tb.focusedPane().screen = vt.NewScreen(12, 3)166 tb.focusedPane().screen = vt.NewScreen(12, 3)
148 tb.focusedPane().screen.Write([]byte("live"))167 tb.focusedPane().screen.Write([]byte("live"))
149- snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame, tb.focusedPane().screen.LineBounds())168+ snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame, tb.focusedPane().screen.LineBounds(), nil)
150 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))169 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
151 170
152 bars := barState{status: sess.statusSegments(true)}171 bars := barState{status: sess.statusSegments(true)}
@@ -434,7 +453,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
434 }453 }
435 p.mu.Lock()454 p.mu.Lock()
436 defer p.mu.Unlock()455 defer p.mu.Unlock()
437- return scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()).Len() >= 12456+ return scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil).Len() >= 12
438 }, 2*time.Second, 5*time.Millisecond)457 }, 2*time.Second, 5*time.Millisecond)
439 458
440 sess := firstSession(d)459 sess := firstSession(d)
@@ -784,7 +803,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
784 }803 }
785 fp.screen.Write([]byte("live"))804 fp.screen.Write([]byte("live"))
786 fp.mu.Lock()805 fp.mu.Lock()
787- total := scopy.NewSnapshot(fp.history, fp.screen.Frame, fp.screen.LineBounds()).Len()806+ total := scopy.NewSnapshot(fp.history, fp.screen.Frame, fp.screen.LineBounds(), nil).Len()
788 fp.mu.Unlock()807 fp.mu.Unlock()
789 808
790 d.enterCopyMode(sess, ac)809 d.enterCopyMode(sess, ac)
@@ -895,7 +914,7 @@ func TestComposeCopyClientFrameStylesStatusContentAndSurround(t *testing.T) {
895 base := renderer.NewFrame(20, 8)914 base := renderer.NewFrame(20, 8)
896 pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2})915 pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2})
897 pane.screen.Write([]byte("copy"))916 pane.screen.Write([]byte("copy"))
898- mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds()), domain.DefaultWordSeparators))917+ mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds(), nil), domain.DefaultWordSeparators))
899 if tt.selectMode {918 if tt.selectMode {
900 mode.ToggleLineSelection()919 mode.ToggleLineSelection()
901 }920 }
M internal/usecase/daemon/daemon_test.go +15 -1
@@ -458,7 +458,21 @@ func testRow(text string) []renderer.Cell {
458458 // wrapped out of. Tests that care about wrapped extents build the bound instead.
459459 func appendHistoryRow(tb testing.TB, history *vt.History, row []renderer.Cell) {
460460 tb.Helper()
461- require.NoError(tb, history.Append(row, vt.LineBound{End: len(row)}))
461+ id := max(vt.RowID(1_000_000), history.NextRowID())
462+ require.NoError(tb, history.AppendWithID(row, vt.LineBound{End: len(row)}, id))
463+}
464+
465+func TestAppendHistoryRowAllocatesUniqueIDsAfterEviction(t *testing.T) {
466+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})
467+ ids := make(map[vt.RowID]struct{})
468+ for range 4 {
469+ appendHistoryRow(t, history, testRow("row"))
470+ view := history.View()
471+ id := view.RowID(view.Len() - 1)
472+ require.NotContains(t, ids, id)
473+ ids[id] = struct{}{}
474+ }
475+ require.Len(t, ids, 4)
462476 }
463477
464478 func tabCount(sess *session) int {
@@ -458,7 +458,21 @@ func testRow(text string) []renderer.Cell {
458 // wrapped out of. Tests that care about wrapped extents build the bound instead.458 // wrapped out of. Tests that care about wrapped extents build the bound instead.
459 func appendHistoryRow(tb testing.TB, history *vt.History, row []renderer.Cell) {459 func appendHistoryRow(tb testing.TB, history *vt.History, row []renderer.Cell) {
460 tb.Helper()460 tb.Helper()
461- require.NoError(tb, history.Append(row, vt.LineBound{End: len(row)}))461+ id := max(vt.RowID(1_000_000), history.NextRowID())
462+ require.NoError(tb, history.AppendWithID(row, vt.LineBound{End: len(row)}, id))
463+}
464+
465+func TestAppendHistoryRowAllocatesUniqueIDsAfterEviction(t *testing.T) {
466+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})
467+ ids := make(map[vt.RowID]struct{})
468+ for range 4 {
469+ appendHistoryRow(t, history, testRow("row"))
470+ view := history.View()
471+ id := view.RowID(view.Len() - 1)
472+ require.NotContains(t, ids, id)
473+ ids[id] = struct{}{}
474+ }
475+ require.Len(t, ids, 4)
462 }476 }
463 477
464 func tabCount(sess *session) int {478 func tabCount(sess *session) int {
M internal/usecase/daemon/overlay_runtime_test.go +1 -1
@@ -200,7 +200,7 @@ func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) {
200200 ac.initOverlays()
201201 history := newTestHistory(1)
202202 appendHistoryRow(t, history, testRow("before"))
203- document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1), nil), domain.DefaultWordSeparators)
203+ document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1), nil, nil), domain.DefaultWordSeparators)
204204
205205 ac.overlays.copyMu.Lock()
206206 ac.overlays.copyMode = scopy.NewMode(document)
@@ -200,7 +200,7 @@ func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) {
200 ac.initOverlays()200 ac.initOverlays()
201 history := newTestHistory(1)201 history := newTestHistory(1)
202 appendHistoryRow(t, history, testRow("before"))202 appendHistoryRow(t, history, testRow("before"))
203- document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1), nil), domain.DefaultWordSeparators)203+ document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1), nil, nil), domain.DefaultWordSeparators)
204 204
205 ac.overlays.copyMu.Lock()205 ac.overlays.copyMu.Lock()
206 ac.overlays.copyMode = scopy.NewMode(document)206 ac.overlays.copyMode = scopy.NewMode(document)
M internal/usecase/daemon/render_test.go +1 -1
@@ -1273,7 +1273,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
12731273 }
12741274 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})
12751275 p.screen.Write([]byte("ab\r\ncd"))
1276- document := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds())
1276+ document := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil)
12771277 mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators))
12781278 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
12791279
@@ -1273,7 +1273,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
1273 }1273 }
1274 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})1274 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})
1275 p.screen.Write([]byte("ab\r\ncd"))1275 p.screen.Write([]byte("ab\r\ncd"))
1276- document := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds())1276+ document := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), nil)
1277 mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators))1277 mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators))
1278 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}1278 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
1279 1279
M internal/usecase/daemon/snapshot_incremental.go +0 -3
@@ -169,9 +169,6 @@ func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.Snapsho
169169 // general history encoding. An empty tail is represented by its dedicated
170170 // canonical blob; a non-empty copied tail retains its rows.
171171 func marshalSnapshotTail(tail vt.HistoryView) ([]byte, error) {
172- if tail.Len() == 0 {
173- return vt.MarshalEmptyHistoryTail()
174- }
175172 return vt.MarshalHistory(tail)
176173 }
177174
@@ -169,9 +169,6 @@ func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.Snapsho
169 // general history encoding. An empty tail is represented by its dedicated169 // general history encoding. An empty tail is represented by its dedicated
170 // canonical blob; a non-empty copied tail retains its rows.170 // canonical blob; a non-empty copied tail retains its rows.
171 func marshalSnapshotTail(tail vt.HistoryView) ([]byte, error) {171 func marshalSnapshotTail(tail vt.HistoryView) ([]byte, error) {
172- if tail.Len() == 0 {
173- return vt.MarshalEmptyHistoryTail()
174- }
175 return vt.MarshalHistory(tail)172 return vt.MarshalHistory(tail)
176 }173 }
177 174
M internal/usecase/daemon/status_test.go +9 -2
@@ -634,8 +634,10 @@ func assertSessionDefaultColors(t *testing.T, sess *session, fg, bg renderer.RGB
634634 func assertPaneDefaultColors(t *testing.T, p *pane, fg, bg renderer.RGB) {
635635 t.Helper()
636636 var got []byte
637+ p.mu.Lock()
637638 p.screen.OnResponse = func(b []byte) { got = append(got, b...) }
638639 p.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
640+ p.mu.Unlock()
639641 require.True(t, strings.Contains(string(got), formatOSCColor(fg)), string(got))
640642 require.True(t, strings.Contains(string(got), formatOSCColor(bg)), string(got))
641643 }
@@ -654,17 +656,22 @@ func assertPaneColorScheme(t *testing.T, p *pane, light bool) {
654656 want = "\x1b[?997;2n"
655657 }
656658 var got []byte
659+ p.mu.Lock()
657660 p.screen.OnResponse = func(b []byte) { got = append(got, b...) }
658661 p.screen.Write([]byte("\x1b[?996n"))
662+ p.mu.Unlock()
659663 require.Equal(t, want, string(got))
660664 }
661665
662666 func assertSessionColorSchemeUnknown(t *testing.T, sess *session) {
663667 t.Helper()
664668 for _, tb := range sess.tabs {
669+ p := tb.focusedPane()
665670 var got []byte
666- tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
667- tb.focusedPane().screen.Write([]byte("\x1b[?996n"))
671+ p.mu.Lock()
672+ p.screen.OnResponse = func(b []byte) { got = append(got, b...) }
673+ p.screen.Write([]byte("\x1b[?996n"))
674+ p.mu.Unlock()
668675 require.Empty(t, got)
669676 }
670677 }
@@ -634,8 +634,10 @@ func assertSessionDefaultColors(t *testing.T, sess *session, fg, bg renderer.RGB
634 func assertPaneDefaultColors(t *testing.T, p *pane, fg, bg renderer.RGB) {634 func assertPaneDefaultColors(t *testing.T, p *pane, fg, bg renderer.RGB) {
635 t.Helper()635 t.Helper()
636 var got []byte636 var got []byte
637+ p.mu.Lock()
637 p.screen.OnResponse = func(b []byte) { got = append(got, b...) }638 p.screen.OnResponse = func(b []byte) { got = append(got, b...) }
638 p.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))639 p.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
640+ p.mu.Unlock()
639 require.True(t, strings.Contains(string(got), formatOSCColor(fg)), string(got))641 require.True(t, strings.Contains(string(got), formatOSCColor(fg)), string(got))
640 require.True(t, strings.Contains(string(got), formatOSCColor(bg)), string(got))642 require.True(t, strings.Contains(string(got), formatOSCColor(bg)), string(got))
641 }643 }
@@ -654,17 +656,22 @@ func assertPaneColorScheme(t *testing.T, p *pane, light bool) {
654 want = "\x1b[?997;2n"656 want = "\x1b[?997;2n"
655 }657 }
656 var got []byte658 var got []byte
659+ p.mu.Lock()
657 p.screen.OnResponse = func(b []byte) { got = append(got, b...) }660 p.screen.OnResponse = func(b []byte) { got = append(got, b...) }
658 p.screen.Write([]byte("\x1b[?996n"))661 p.screen.Write([]byte("\x1b[?996n"))
662+ p.mu.Unlock()
659 require.Equal(t, want, string(got))663 require.Equal(t, want, string(got))
660 }664 }
661 665
662 func assertSessionColorSchemeUnknown(t *testing.T, sess *session) {666 func assertSessionColorSchemeUnknown(t *testing.T, sess *session) {
663 t.Helper()667 t.Helper()
664 for _, tb := range sess.tabs {668 for _, tb := range sess.tabs {
669+ p := tb.focusedPane()
665 var got []byte670 var got []byte
666- tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }671+ p.mu.Lock()
667- tb.focusedPane().screen.Write([]byte("\x1b[?996n"))672+ p.screen.OnResponse = func(b []byte) { got = append(got, b...) }
673+ p.screen.Write([]byte("\x1b[?996n"))
674+ p.mu.Unlock()
668 require.Empty(t, got)675 require.Empty(t, got)
669 }676 }
670 }677 }
M internal/usecase/snapshot/codec_test.go +8 -7
@@ -224,13 +224,14 @@ func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) {
224224
225225 func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) {
226226 sealed, emptyTail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}})
227- if len(sealed) == 0 {
228- t.Fatal("expected a sealed chunk")
229- }
230- multiChunk := append([]byte(nil), sealed[0][:5]...)
231- multiChunk = binary.BigEndian.AppendUint32(multiChunk, 2)
232- multiChunk = append(multiChunk, sealed[0][9:]...)
233- multiChunk = append(multiChunk, sealed[0][9:]...)
227+ if len(sealed) < 2 {
228+ t.Fatalf("expected at least two sealed chunks, got %d", len(sealed))
229+ }
230+ multiChunk := append([]byte(nil), sealed[0][:17]...)
231+ binary.BigEndian.PutUint32(multiChunk[5:9], 2)
232+ binary.BigEndian.PutUint64(multiChunk[9:17], 4)
233+ multiChunk = append(multiChunk, sealed[0][17:]...)
234+ multiChunk = append(multiChunk, sealed[1][17:]...)
234235 transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
235236 emptyTranscript := transcriptBlob(t, nil)
236237
@@ -224,13 +224,14 @@ func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) {
224 224
225 func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) {225 func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) {
226 sealed, emptyTail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}})226 sealed, emptyTail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}})
227- if len(sealed) == 0 {227+ if len(sealed) < 2 {
228- t.Fatal("expected a sealed chunk")228+ t.Fatalf("expected at least two sealed chunks, got %d", len(sealed))
229- }229+ }
230- multiChunk := append([]byte(nil), sealed[0][:5]...)230+ multiChunk := append([]byte(nil), sealed[0][:17]...)
231- multiChunk = binary.BigEndian.AppendUint32(multiChunk, 2)231+ binary.BigEndian.PutUint32(multiChunk[5:9], 2)
232- multiChunk = append(multiChunk, sealed[0][9:]...)232+ binary.BigEndian.PutUint64(multiChunk[9:17], 4)
233- multiChunk = append(multiChunk, sealed[0][9:]...)233+ multiChunk = append(multiChunk, sealed[0][17:]...)
234+ multiChunk = append(multiChunk, sealed[1][17:]...)
234 transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})235 transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
235 emptyTranscript := transcriptBlob(t, nil)236 emptyTranscript := transcriptBlob(t, nil)
236 237
M pkg/vt/buffer.go +48 -11
@@ -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 }
@@ -248,7 +260,7 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]ren
248260 }
249261
250262 points := b.cursorReflowPoints(active, saved)
251- rows := b.layoutReflow(width, &points, nil, 0, nil, nil)
263+ rows := b.layoutReflow(width, &points, nil, 0, nil, nil, nil, nil)
252264 anchor := 0
253265 if active != nil {
254266 anchor = points[0].row
@@ -259,17 +271,19 @@ 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 }
271285 }
272- b.layoutReflow(width, &points, next, shift, evictedCells, evictedBounds)
286+ b.layoutReflow(width, &points, next, shift, b.rowIDs, evictedCells, evictedBounds, evictedIDs)
273287 for i, cur := range [2]*bufferCursor{active, saved} {
274288 if cur != nil {
275289 cur.row = clamp(points[i].row-shift, 0, height-1)
@@ -277,14 +291,27 @@ 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
284298 // [shift, shift+dst.height). Rows before shift are written straight into the
285299 // contiguous eviction backing store. The return is the logical output height.
286-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 {
287301 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+ }
288315 blankEvicted := func(outputRow int) []renderer.Cell {
289316 if outputRow < shift {
290317 out := evicted[outputRow*width : (outputRow+1)*width]
@@ -342,6 +369,16 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh
342369 }
343370 reflow := end > start
344371 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+ }
345382 truncated := false
346383 for y := start; y <= end && !truncated; y++ {
347384 cells := b.frame.Row(y)
@@ -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 }
@@ -248,7 +260,7 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]ren
248 }260 }
249 261
250 points := b.cursorReflowPoints(active, saved)262 points := b.cursorReflowPoints(active, saved)
251- rows := b.layoutReflow(width, &points, nil, 0, nil, nil)263+ rows := b.layoutReflow(width, &points, nil, 0, nil, nil, nil, nil)
252 anchor := 0264 anchor := 0
253 if active != nil {265 if active != nil {
254 anchor = points[0].row266 anchor = points[0].row
@@ -259,17 +271,19 @@ 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 }
271 }285 }
272- b.layoutReflow(width, &points, next, shift, evictedCells, evictedBounds)286+ b.layoutReflow(width, &points, next, shift, b.rowIDs, evictedCells, evictedBounds, evictedIDs)
273 for i, cur := range [2]*bufferCursor{active, saved} {287 for i, cur := range [2]*bufferCursor{active, saved} {
274 if cur != nil {288 if cur != nil {
275 cur.row = clamp(points[i].row-shift, 0, height-1)289 cur.row = clamp(points[i].row-shift, 0, height-1)
@@ -277,14 +291,27 @@ 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
284 // [shift, shift+dst.height). Rows before shift are written straight into the298 // [shift, shift+dst.height). Rows before shift are written straight into the
285 // contiguous eviction backing store. The return is the logical output height.299 // contiguous eviction backing store. The return is the logical output height.
286-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 {
287 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+ }
288 blankEvicted := func(outputRow int) []renderer.Cell {315 blankEvicted := func(outputRow int) []renderer.Cell {
289 if outputRow < shift {316 if outputRow < shift {
290 out := evicted[outputRow*width : (outputRow+1)*width]317 out := evicted[outputRow*width : (outputRow+1)*width]
@@ -342,6 +369,16 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh
342 }369 }
343 reflow := end > start370 reflow := end > start
344 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+ }
345 truncated := false382 truncated := false
346 for y := start; y <= end && !truncated; y++ {383 for y := start; y <= end && !truncated; y++ {
347 cells := b.frame.Row(y)384 cells := b.frame.Row(y)
M pkg/vt/history.go +203 -19
@@ -3,6 +3,7 @@ package vt
33 import (
44 "errors"
55 "math"
6+ "slices"
67
78 "github.com/bnema/vev/pkg/renderer"
89 )
@@ -11,6 +12,8 @@ import (
1112 // cell budget. The history is not modified when this error is returned.
1213 var ErrHistoryRowTooWide = errors.New("history row exceeds cell capacity")
1314
15+var errInvalidHistoryRowID = errors.New("invalid history row ID")
16+
1417 // HistoryConfig controls the bounded terminal history retained by a Screen.
1518 type HistoryConfig struct {
1619 MaxRows int
@@ -28,8 +31,10 @@ type History struct {
2831 chunks []*HistoryChunk
2932 tail [][]renderer.Cell
3033 tailBounds []LineBound
34+ tailIDs []RowID
3135 rows int
3236 cells int
37+ nextRowID RowID
3338 }
3439
3540 // HistoryChunk is an immutable group of history rows. Its identity is stable
@@ -38,14 +43,16 @@ type History struct {
3843 type HistoryChunk struct {
3944 rows [][]renderer.Cell
4045 bounds []LineBound
46+ rowIDs []RowID
4147 }
4248
4349 // HistoryView is an immutable snapshot of history. Row returns a copy so the
4450 // storage behind a sealed chunk remains owned by VT.
4551 type HistoryView struct {
46- chunks []*HistoryChunk
47- rows int
48- cells int
52+ chunks []*HistoryChunk
53+ rows int
54+ cells int
55+ nextRowID RowID
4956 }
5057
5158 // HistorySnapshotView captures sealed history chunks and the mutable tail
@@ -55,8 +62,10 @@ type HistorySnapshotView struct {
5562 chunks []*HistoryChunk
5663 tail [][]renderer.Cell
5764 tailBounds []LineBound
65+ tailIDs []RowID
5866 rows int
5967 cells int
68+ nextRowID RowID
6069 }
6170
6271 func NewHistory(config HistoryConfig) *History {
@@ -73,7 +82,7 @@ func NewHistory(config HistoryConfig) *History {
7382 if maxCells <= 0 {
7483 maxCells = defaultHistoryMaxCells(config.MaxRows)
7584 }
76- return &History{maxRows: config.MaxRows, maxCells: maxCells, chunkRows: chunkRows}
85+ return &History{maxRows: config.MaxRows, maxCells: maxCells, chunkRows: chunkRows, nextRowID: 1}
7786 }
7887
7988 func defaultHistoryMaxCells(maxRows int) int {
@@ -83,9 +92,10 @@ func defaultHistoryMaxCells(maxRows int) int {
8392 return maxRows * 160
8493 }
8594
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.
95+// Append records a copy of row along with its logical extent and an
96+// automatically allocated nonzero ID. Once a chunk is full it is sealed
97+// forever. Rows wider than the total cell capacity are rejected without
98+// mutation.
8999 func (h *History) Append(row []renderer.Cell, bound LineBound) error {
90100 if h == nil || h.maxRows == 0 {
91101 return nil
@@ -93,11 +103,50 @@ func (h *History) Append(row []renderer.Cell, bound LineBound) error {
93103 if len(row) > h.maxCells {
94104 return ErrHistoryRowTooWide
95105 }
106+ id, err := h.allocateRowID()
107+ if err != nil {
108+ return err
109+ }
110+ return h.appendRow(row, bound, id)
111+}
112+
113+// AppendWithID records a copy of row with an explicit persisted identity.
114+// Unlike Append, malformed identities are rejected rather than synthesized.
115+func (h *History) AppendWithID(row []renderer.Cell, bound LineBound, id RowID) error {
116+ if id == 0 || id >= ^RowID(0)-1 {
117+ return errInvalidHistoryRowID
118+ }
119+ if h == nil || h.maxRows == 0 {
120+ return nil
121+ }
122+ if len(row) > h.maxCells {
123+ return ErrHistoryRowTooWide
124+ }
125+ if h.hasRowID(id) {
126+ return errInvalidHistoryRowID
127+ }
128+ return h.appendRow(row, bound, id)
129+}
130+
131+func (h *History) appendRow(row []renderer.Cell, bound LineBound, id RowID) error {
132+ if h.nextRowID == ^RowID(0) {
133+ return errInvalidHistoryRowID
134+ }
135+ if len(row) > h.maxCells {
136+ return ErrHistoryRowTooWide
137+ }
138+ if id >= ^RowID(0)-1 {
139+ return errInvalidHistoryRowID
140+ }
141+ if id >= h.nextRowID {
142+ h.nextRowID = id + 1
143+ }
96144 // Make room before adding so Cells cannot overflow even when the default
97145 // capacity is MaxInt.
98146 h.evictFor(len(row))
99147 h.tail = append(h.tail, append([]renderer.Cell(nil), row...))
100148 h.tailBounds = append(h.tailBounds, clampBound(bound, len(row)))
149+ h.tailIDs = append(h.tailIDs, id)
101150 h.rows++
102151 h.cells += len(row)
103152 if len(h.tail) == h.chunkRows {
@@ -106,6 +155,29 @@ func (h *History) Append(row []renderer.Cell, bound LineBound) error {
106155 return nil
107156 }
108157
158+// allocateRowID relies on appendRow advancing nextRowID above every accepted
159+// explicit ID; automatic IDs therefore never need to scan retained rows.
160+func (h *History) allocateRowID() (RowID, error) {
161+ if h.nextRowID == 0 {
162+ h.nextRowID = 1
163+ }
164+ if h.nextRowID >= ^RowID(0)-1 {
165+ return 0, errInvalidHistoryRowID
166+ }
167+ id := h.nextRowID
168+ h.nextRowID++
169+ return id, nil
170+}
171+
172+func (h *History) hasRowID(id RowID) bool {
173+ for _, chunk := range h.chunks {
174+ if slices.Contains(chunk.rowIDs, id) {
175+ return true
176+ }
177+ }
178+ return slices.Contains(h.tailIDs, id)
179+}
180+
109181 // clampBound keeps End inside the row it describes so a persisted bound can be
110182 // validated without consulting its row again.
111183 func clampBound(b LineBound, cells int) LineBound {
@@ -124,26 +196,50 @@ func growBounds(bounds []LineBound, rows int) []LineBound {
124196 return out
125197 }
126198
199+func growRowIDs(ids []RowID, rows int) []RowID {
200+ if len(ids) == rows {
201+ return ids
202+ }
203+ out := make([]RowID, rows)
204+ copy(out, ids)
205+ return out
206+}
207+
208+func cloneRowIDs(ids []RowID, rows int) []RowID {
209+ out := make([]RowID, rows)
210+ copy(out, ids)
211+ return out
212+}
213+
127214 func (h *History) sealTail() {
128215 if len(h.tail) == 0 {
129216 return
130217 }
131- h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail, bounds: growBounds(h.tailBounds, len(h.tail))})
218+ h.chunks = append(h.chunks, &HistoryChunk{
219+ rows: h.tail,
220+ bounds: growBounds(h.tailBounds, len(h.tail)),
221+ rowIDs: h.tailIDs,
222+ })
132223 h.tail = nil
133224 h.tailBounds = nil
225+ h.tailIDs = nil
134226 }
135227
136228 // normalizeTail seals complete chunks so the mutable tail remains shorter than
137229 // chunkRows. Restored snapshots may have been written with a different chunk
138230 // size than the current history configuration.
139231 func (h *History) normalizeTail() {
232+ h.tailIDs = growRowIDs(h.tailIDs, len(h.tail))
140233 for h.chunkRows > 0 && len(h.tail) >= h.chunkRows {
234+ bounds := growBounds(h.tailBounds, len(h.tail))
141235 h.chunks = append(h.chunks, &HistoryChunk{
142236 rows: h.tail[:h.chunkRows],
143- bounds: growBounds(h.tailBounds, len(h.tail))[:h.chunkRows],
237+ bounds: bounds[:h.chunkRows],
238+ rowIDs: h.tailIDs[:h.chunkRows],
144239 })
145240 h.tail = h.tail[h.chunkRows:]
146- h.tailBounds = growBounds(h.tailBounds, len(h.tail)+h.chunkRows)[h.chunkRows:]
241+ h.tailBounds = bounds[h.chunkRows:]
242+ h.tailIDs = h.tailIDs[h.chunkRows:]
147243 }
148244 }
149245
@@ -172,7 +268,13 @@ func (h *History) evictUntil(rowCount, cellCount int) {
172268 } else {
173269 // Preserve cell storage while replacing only the chunk wrapper: a
174270 // 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:]}
271+ bounds := growBounds(chunk.bounds, len(chunk.rows))
272+ rowIDs := growRowIDs(chunk.rowIDs, len(chunk.rows))
273+ h.chunks[0] = &HistoryChunk{
274+ rows: chunk.rows[1:],
275+ bounds: bounds[1:],
276+ rowIDs: rowIDs[1:],
277+ }
176278 }
177279 continue
178280 }
@@ -185,6 +287,7 @@ func (h *History) evictUntil(rowCount, cellCount int) {
185287 h.tail[0] = nil
186288 h.tail = h.tail[1:]
187289 h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:]
290+ h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)+1)[1:]
188291 }
189292 }
190293
@@ -200,31 +303,41 @@ func (h *History) SealAndView() HistoryView {
200303 // View captures the current history. Sealed chunks are shared by identity; a
201304 // partially-filled tail is copied into a new immutable chunk for this view.
202305 func (h *History) View() HistoryView {
203- if h == nil || h.rows == 0 {
306+ if h == nil {
204307 return HistoryView{}
205308 }
206- chunks := append([]*HistoryChunk(nil), h.chunks...)
309+ if h.rows == 0 {
310+ return HistoryView{nextRowID: h.nextRowID}
311+ }
312+ chunks := make([]*HistoryChunk, len(h.chunks), len(h.chunks)+1)
313+ copy(chunks, h.chunks)
207314 if len(h.tail) > 0 {
208315 chunks = append(chunks, &HistoryChunk{
209316 rows: cloneHistoryRows(h.tail),
210317 bounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),
318+ rowIDs: cloneRowIDs(h.tailIDs, len(h.tail)),
211319 })
212320 }
213- return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells}
321+ return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells, nextRowID: h.nextRowID}
214322 }
215323
216324 // SnapshotView captures history for persistence without sealing the mutable
217325 // tail. Sealed chunks are shared by identity and the tail is deeply copied.
218326 func (h *History) SnapshotView() HistorySnapshotView {
219- if h == nil || h.rows == 0 {
327+ if h == nil {
220328 return HistorySnapshotView{}
221329 }
330+ if h.rows == 0 {
331+ return HistorySnapshotView{nextRowID: h.nextRowID}
332+ }
222333 return HistorySnapshotView{
223334 chunks: append([]*HistoryChunk(nil), h.chunks...),
224335 tail: cloneHistoryRows(h.tail),
225336 tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),
337+ tailIDs: cloneRowIDs(h.tailIDs, len(h.tail)),
226338 rows: h.rows,
227339 cells: h.cells,
340+ nextRowID: h.nextRowID,
228341 }
229342 }
230343
@@ -255,6 +368,14 @@ func (h *History) Cells() int {
255368 return h.cells
256369 }
257370
371+// NextRowID returns the next identity allocated by this history.
372+func (h *History) NextRowID() RowID {
373+ if h == nil || h.nextRowID == 0 {
374+ return 1
375+ }
376+ return h.nextRowID
377+}
378+
258379 // Cap returns the configured bounded row capacity.
259380 func (h *History) Cap() int {
260381 if h == nil {
@@ -275,6 +396,18 @@ func (v HistoryView) Len() int { return v.rows }
275396 func (v HistoryView) Cells() int { return v.cells }
276397 func (v HistoryView) ChunkCount() int { return len(v.chunks) }
277398
399+// NextRowID returns the next identity recorded by this view.
400+func (v HistoryView) NextRowID() RowID {
401+ if v.nextRowID == 0 {
402+ next, ok := historyViewNextRowID(v)
403+ if !ok {
404+ return 1
405+ }
406+ return next
407+ }
408+ return v.nextRowID
409+}
410+
278411 // Chunk returns the immutable chunk at i, or nil when i is out of range.
279412 func (v HistoryView) Chunk(i int) *HistoryChunk {
280413 if i < 0 || i >= len(v.chunks) {
@@ -316,6 +449,48 @@ func (v HistoryView) BorrowedRow(i int) []renderer.Cell {
316449 return nil
317450 }
318451
452+// RowID returns the identity of the row at i, or zero when i is out of range.
453+func (v HistoryView) RowID(i int) RowID {
454+ if i < 0 {
455+ return 0
456+ }
457+ for _, chunk := range v.chunks {
458+ if i < len(chunk.rows) {
459+ if i < len(chunk.rowIDs) {
460+ return chunk.rowIDs[i]
461+ }
462+ return 0
463+ }
464+ i -= len(chunk.rows)
465+ }
466+ return 0
467+}
468+
469+// FindRowID returns the oldest-first index of id, or -1 when id is absent.
470+func (v HistoryView) FindRowID(id RowID) int {
471+ if id == 0 {
472+ return -1
473+ }
474+ index := 0
475+ for _, chunk := range v.chunks {
476+ for row := range chunk.rows {
477+ if row < len(chunk.rowIDs) && chunk.rowIDs[row] == id {
478+ return index
479+ }
480+ index++
481+ }
482+ }
483+ return -1
484+}
485+
486+// RowID returns the identity of the row within an immutable chunk.
487+func (c *HistoryChunk) RowID(i int) RowID {
488+ if c == nil || i < 0 || i >= len(c.rowIDs) {
489+ return 0
490+ }
491+ return c.rowIDs[i]
492+}
493+
319494 // Bound returns the logical extent of the row at i, or the zero value when i is
320495 // out of range. A zero value describes a hard row whose End is not meaningful.
321496 func (v HistoryView) Bound(i int) LineBound {
@@ -338,6 +513,14 @@ func (v HistorySnapshotView) Len() int { return v.rows }
338513 func (v HistorySnapshotView) Cells() int { return v.cells }
339514 func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) }
340515
516+// NextRowID returns the next identity recorded by this snapshot.
517+func (v HistorySnapshotView) NextRowID() RowID {
518+ if v.nextRowID == 0 {
519+ return v.Tail().NextRowID()
520+ }
521+ return v.nextRowID
522+}
523+
341524 // Chunk returns a sealed immutable chunk at i, or nil when i is out of range.
342525 func (v HistorySnapshotView) Chunk(i int) *HistoryChunk {
343526 if i < 0 || i >= len(v.chunks) {
@@ -349,12 +532,13 @@ func (v HistorySnapshotView) Chunk(i int) *HistoryChunk {
349532 // Tail returns an immutable view of the copied mutable tail.
350533 func (v HistorySnapshotView) Tail() HistoryView {
351534 if len(v.tail) == 0 {
352- return HistoryView{}
535+ return HistoryView{nextRowID: v.nextRowID}
353536 }
354537 return HistoryView{
355- chunks: []*HistoryChunk{{rows: v.tail, bounds: growBounds(v.tailBounds, len(v.tail))}},
356- rows: len(v.tail),
357- cells: historyRowsCells(v.tail),
538+ chunks: []*HistoryChunk{{rows: v.tail, bounds: growBounds(v.tailBounds, len(v.tail)), rowIDs: growRowIDs(v.tailIDs, len(v.tail))}},
539+ rows: len(v.tail),
540+ cells: historyRowsCells(v.tail),
541+ nextRowID: v.nextRowID,
358542 }
359543 }
360544
@@ -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 )
@@ -11,6 +12,8 @@ import (
11 // cell budget. The history is not modified when this error is returned.12 // cell budget. The history is not modified when this error is returned.
12 var ErrHistoryRowTooWide = errors.New("history row exceeds cell capacity")13 var ErrHistoryRowTooWide = errors.New("history row exceeds cell capacity")
13 14
15+var errInvalidHistoryRowID = errors.New("invalid history row ID")
16+
14 // HistoryConfig controls the bounded terminal history retained by a Screen.17 // HistoryConfig controls the bounded terminal history retained by a Screen.
15 type HistoryConfig struct {18 type HistoryConfig struct {
16 MaxRows int19 MaxRows int
@@ -28,8 +31,10 @@ type History struct {
28 chunks []*HistoryChunk31 chunks []*HistoryChunk
29 tail [][]renderer.Cell32 tail [][]renderer.Cell
30 tailBounds []LineBound33 tailBounds []LineBound
34+ tailIDs []RowID
31 rows int35 rows int
32 cells int36 cells int
37+ nextRowID RowID
33 }38 }
34 39
35 // HistoryChunk is an immutable group of history rows. Its identity is stable40 // HistoryChunk is an immutable group of history rows. Its identity is stable
@@ -38,14 +43,16 @@ type History struct {
38 type HistoryChunk struct {43 type HistoryChunk struct {
39 rows [][]renderer.Cell44 rows [][]renderer.Cell
40 bounds []LineBound45 bounds []LineBound
46+ rowIDs []RowID
41 }47 }
42 48
43 // HistoryView is an immutable snapshot of history. Row returns a copy so the49 // HistoryView is an immutable snapshot of history. Row returns a copy so the
44 // storage behind a sealed chunk remains owned by VT.50 // storage behind a sealed chunk remains owned by VT.
45 type HistoryView struct {51 type HistoryView struct {
46- chunks []*HistoryChunk52+ chunks []*HistoryChunk
47- rows int53+ rows int
48- cells int54+ cells int
55+ nextRowID RowID
49 }56 }
50 57
51 // HistorySnapshotView captures sealed history chunks and the mutable tail58 // HistorySnapshotView captures sealed history chunks and the mutable tail
@@ -55,8 +62,10 @@ type HistorySnapshotView struct {
55 chunks []*HistoryChunk62 chunks []*HistoryChunk
56 tail [][]renderer.Cell63 tail [][]renderer.Cell
57 tailBounds []LineBound64 tailBounds []LineBound
65+ tailIDs []RowID
58 rows int66 rows int
59 cells int67 cells int
68+ nextRowID RowID
60 }69 }
61 70
62 func NewHistory(config HistoryConfig) *History {71 func NewHistory(config HistoryConfig) *History {
@@ -73,7 +82,7 @@ func NewHistory(config HistoryConfig) *History {
73 if maxCells <= 0 {82 if maxCells <= 0 {
74 maxCells = defaultHistoryMaxCells(config.MaxRows)83 maxCells = defaultHistoryMaxCells(config.MaxRows)
75 }84 }
76- return &History{maxRows: config.MaxRows, maxCells: maxCells, chunkRows: chunkRows}85+ return &History{maxRows: config.MaxRows, maxCells: maxCells, chunkRows: chunkRows, nextRowID: 1}
77 }86 }
78 87
79 func defaultHistoryMaxCells(maxRows int) int {88 func defaultHistoryMaxCells(maxRows int) int {
@@ -83,9 +92,10 @@ func defaultHistoryMaxCells(maxRows int) int {
83 return maxRows * 16092 return maxRows * 160
84 }93 }
85 94
86-// Append records a copy of row along with its logical extent. Once a chunk is95+// Append records a copy of row along with its logical extent and an
87-// full it is sealed forever. Rows wider than the total cell capacity are96+// automatically allocated nonzero ID. Once a chunk is full it is sealed
88-// rejected without mutation.97+// forever. Rows wider than the total cell capacity are rejected without
98+// mutation.
89 func (h *History) Append(row []renderer.Cell, bound LineBound) error {99 func (h *History) Append(row []renderer.Cell, bound LineBound) error {
90 if h == nil || h.maxRows == 0 {100 if h == nil || h.maxRows == 0 {
91 return nil101 return nil
@@ -93,11 +103,50 @@ func (h *History) Append(row []renderer.Cell, bound LineBound) error {
93 if len(row) > h.maxCells {103 if len(row) > h.maxCells {
94 return ErrHistoryRowTooWide104 return ErrHistoryRowTooWide
95 }105 }
106+ id, err := h.allocateRowID()
107+ if err != nil {
108+ return err
109+ }
110+ return h.appendRow(row, bound, id)
111+}
112+
113+// AppendWithID records a copy of row with an explicit persisted identity.
114+// Unlike Append, malformed identities are rejected rather than synthesized.
115+func (h *History) AppendWithID(row []renderer.Cell, bound LineBound, id RowID) error {
116+ if id == 0 || id >= ^RowID(0)-1 {
117+ return errInvalidHistoryRowID
118+ }
119+ if h == nil || h.maxRows == 0 {
120+ return nil
121+ }
122+ if len(row) > h.maxCells {
123+ return ErrHistoryRowTooWide
124+ }
125+ if h.hasRowID(id) {
126+ return errInvalidHistoryRowID
127+ }
128+ return h.appendRow(row, bound, id)
129+}
130+
131+func (h *History) appendRow(row []renderer.Cell, bound LineBound, id RowID) error {
132+ if h.nextRowID == ^RowID(0) {
133+ return errInvalidHistoryRowID
134+ }
135+ if len(row) > h.maxCells {
136+ return ErrHistoryRowTooWide
137+ }
138+ if id >= ^RowID(0)-1 {
139+ return errInvalidHistoryRowID
140+ }
141+ if id >= h.nextRowID {
142+ h.nextRowID = id + 1
143+ }
96 // Make room before adding so Cells cannot overflow even when the default144 // Make room before adding so Cells cannot overflow even when the default
97 // capacity is MaxInt.145 // capacity is MaxInt.
98 h.evictFor(len(row))146 h.evictFor(len(row))
99 h.tail = append(h.tail, append([]renderer.Cell(nil), row...))147 h.tail = append(h.tail, append([]renderer.Cell(nil), row...))
100 h.tailBounds = append(h.tailBounds, clampBound(bound, len(row)))148 h.tailBounds = append(h.tailBounds, clampBound(bound, len(row)))
149+ h.tailIDs = append(h.tailIDs, id)
101 h.rows++150 h.rows++
102 h.cells += len(row)151 h.cells += len(row)
103 if len(h.tail) == h.chunkRows {152 if len(h.tail) == h.chunkRows {
@@ -106,6 +155,29 @@ func (h *History) Append(row []renderer.Cell, bound LineBound) error {
106 return nil155 return nil
107 }156 }
108 157
158+// allocateRowID relies on appendRow advancing nextRowID above every accepted
159+// explicit ID; automatic IDs therefore never need to scan retained rows.
160+func (h *History) allocateRowID() (RowID, error) {
161+ if h.nextRowID == 0 {
162+ h.nextRowID = 1
163+ }
164+ if h.nextRowID >= ^RowID(0)-1 {
165+ return 0, errInvalidHistoryRowID
166+ }
167+ id := h.nextRowID
168+ h.nextRowID++
169+ return id, nil
170+}
171+
172+func (h *History) hasRowID(id RowID) bool {
173+ for _, chunk := range h.chunks {
174+ if slices.Contains(chunk.rowIDs, id) {
175+ return true
176+ }
177+ }
178+ return slices.Contains(h.tailIDs, id)
179+}
180+
109 // clampBound keeps End inside the row it describes so a persisted bound can be181 // clampBound keeps End inside the row it describes so a persisted bound can be
110 // validated without consulting its row again.182 // validated without consulting its row again.
111 func clampBound(b LineBound, cells int) LineBound {183 func clampBound(b LineBound, cells int) LineBound {
@@ -124,26 +196,50 @@ func growBounds(bounds []LineBound, rows int) []LineBound {
124 return out196 return out
125 }197 }
126 198
199+func growRowIDs(ids []RowID, rows int) []RowID {
200+ if len(ids) == rows {
201+ return ids
202+ }
203+ out := make([]RowID, rows)
204+ copy(out, ids)
205+ return out
206+}
207+
208+func cloneRowIDs(ids []RowID, rows int) []RowID {
209+ out := make([]RowID, rows)
210+ copy(out, ids)
211+ return out
212+}
213+
127 func (h *History) sealTail() {214 func (h *History) sealTail() {
128 if len(h.tail) == 0 {215 if len(h.tail) == 0 {
129 return216 return
130 }217 }
131- h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail, bounds: growBounds(h.tailBounds, len(h.tail))})218+ h.chunks = append(h.chunks, &HistoryChunk{
219+ rows: h.tail,
220+ bounds: growBounds(h.tailBounds, len(h.tail)),
221+ rowIDs: h.tailIDs,
222+ })
132 h.tail = nil223 h.tail = nil
133 h.tailBounds = nil224 h.tailBounds = nil
225+ h.tailIDs = nil
134 }226 }
135 227
136 // normalizeTail seals complete chunks so the mutable tail remains shorter than228 // normalizeTail seals complete chunks so the mutable tail remains shorter than
137 // chunkRows. Restored snapshots may have been written with a different chunk229 // chunkRows. Restored snapshots may have been written with a different chunk
138 // size than the current history configuration.230 // size than the current history configuration.
139 func (h *History) normalizeTail() {231 func (h *History) normalizeTail() {
232+ h.tailIDs = growRowIDs(h.tailIDs, len(h.tail))
140 for h.chunkRows > 0 && len(h.tail) >= h.chunkRows {233 for h.chunkRows > 0 && len(h.tail) >= h.chunkRows {
234+ bounds := growBounds(h.tailBounds, len(h.tail))
141 h.chunks = append(h.chunks, &HistoryChunk{235 h.chunks = append(h.chunks, &HistoryChunk{
142 rows: h.tail[:h.chunkRows],236 rows: h.tail[:h.chunkRows],
143- bounds: growBounds(h.tailBounds, len(h.tail))[:h.chunkRows],237+ bounds: bounds[:h.chunkRows],
238+ rowIDs: h.tailIDs[:h.chunkRows],
144 })239 })
145 h.tail = h.tail[h.chunkRows:]240 h.tail = h.tail[h.chunkRows:]
146- h.tailBounds = growBounds(h.tailBounds, len(h.tail)+h.chunkRows)[h.chunkRows:]241+ h.tailBounds = bounds[h.chunkRows:]
242+ h.tailIDs = h.tailIDs[h.chunkRows:]
147 }243 }
148 }244 }
149 245
@@ -172,7 +268,13 @@ func (h *History) evictUntil(rowCount, cellCount int) {
172 } else {268 } else {
173 // Preserve cell storage while replacing only the chunk wrapper: a269 // Preserve cell storage while replacing only the chunk wrapper: a
174 // retained view may still refer to the original immutable chunk.270 // 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:]}271+ bounds := growBounds(chunk.bounds, len(chunk.rows))
272+ rowIDs := growRowIDs(chunk.rowIDs, len(chunk.rows))
273+ h.chunks[0] = &HistoryChunk{
274+ rows: chunk.rows[1:],
275+ bounds: bounds[1:],
276+ rowIDs: rowIDs[1:],
277+ }
176 }278 }
177 continue279 continue
178 }280 }
@@ -185,6 +287,7 @@ func (h *History) evictUntil(rowCount, cellCount int) {
185 h.tail[0] = nil287 h.tail[0] = nil
186 h.tail = h.tail[1:]288 h.tail = h.tail[1:]
187 h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:]289 h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:]
290+ h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)+1)[1:]
188 }291 }
189 }292 }
190 293
@@ -200,31 +303,41 @@ func (h *History) SealAndView() HistoryView {
200 // View captures the current history. Sealed chunks are shared by identity; a303 // View captures the current history. Sealed chunks are shared by identity; a
201 // partially-filled tail is copied into a new immutable chunk for this view.304 // partially-filled tail is copied into a new immutable chunk for this view.
202 func (h *History) View() HistoryView {305 func (h *History) View() HistoryView {
203- if h == nil || h.rows == 0 {306+ if h == nil {
204 return HistoryView{}307 return HistoryView{}
205 }308 }
206- chunks := append([]*HistoryChunk(nil), h.chunks...)309+ if h.rows == 0 {
310+ return HistoryView{nextRowID: h.nextRowID}
311+ }
312+ chunks := make([]*HistoryChunk, len(h.chunks), len(h.chunks)+1)
313+ copy(chunks, h.chunks)
207 if len(h.tail) > 0 {314 if len(h.tail) > 0 {
208 chunks = append(chunks, &HistoryChunk{315 chunks = append(chunks, &HistoryChunk{
209 rows: cloneHistoryRows(h.tail),316 rows: cloneHistoryRows(h.tail),
210 bounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),317 bounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),
318+ rowIDs: cloneRowIDs(h.tailIDs, len(h.tail)),
211 })319 })
212 }320 }
213- return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells}321+ return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells, nextRowID: h.nextRowID}
214 }322 }
215 323
216 // SnapshotView captures history for persistence without sealing the mutable324 // SnapshotView captures history for persistence without sealing the mutable
217 // tail. Sealed chunks are shared by identity and the tail is deeply copied.325 // tail. Sealed chunks are shared by identity and the tail is deeply copied.
218 func (h *History) SnapshotView() HistorySnapshotView {326 func (h *History) SnapshotView() HistorySnapshotView {
219- if h == nil || h.rows == 0 {327+ if h == nil {
220 return HistorySnapshotView{}328 return HistorySnapshotView{}
221 }329 }
330+ if h.rows == 0 {
331+ return HistorySnapshotView{nextRowID: h.nextRowID}
332+ }
222 return HistorySnapshotView{333 return HistorySnapshotView{
223 chunks: append([]*HistoryChunk(nil), h.chunks...),334 chunks: append([]*HistoryChunk(nil), h.chunks...),
224 tail: cloneHistoryRows(h.tail),335 tail: cloneHistoryRows(h.tail),
225 tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),336 tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),
337+ tailIDs: cloneRowIDs(h.tailIDs, len(h.tail)),
226 rows: h.rows,338 rows: h.rows,
227 cells: h.cells,339 cells: h.cells,
340+ nextRowID: h.nextRowID,
228 }341 }
229 }342 }
230 343
@@ -255,6 +368,14 @@ func (h *History) Cells() int {
255 return h.cells368 return h.cells
256 }369 }
257 370
371+// NextRowID returns the next identity allocated by this history.
372+func (h *History) NextRowID() RowID {
373+ if h == nil || h.nextRowID == 0 {
374+ return 1
375+ }
376+ return h.nextRowID
377+}
378+
258 // Cap returns the configured bounded row capacity.379 // Cap returns the configured bounded row capacity.
259 func (h *History) Cap() int {380 func (h *History) Cap() int {
260 if h == nil {381 if h == nil {
@@ -275,6 +396,18 @@ func (v HistoryView) Len() int { return v.rows }
275 func (v HistoryView) Cells() int { return v.cells }396 func (v HistoryView) Cells() int { return v.cells }
276 func (v HistoryView) ChunkCount() int { return len(v.chunks) }397 func (v HistoryView) ChunkCount() int { return len(v.chunks) }
277 398
399+// NextRowID returns the next identity recorded by this view.
400+func (v HistoryView) NextRowID() RowID {
401+ if v.nextRowID == 0 {
402+ next, ok := historyViewNextRowID(v)
403+ if !ok {
404+ return 1
405+ }
406+ return next
407+ }
408+ return v.nextRowID
409+}
410+
278 // Chunk returns the immutable chunk at i, or nil when i is out of range.411 // Chunk returns the immutable chunk at i, or nil when i is out of range.
279 func (v HistoryView) Chunk(i int) *HistoryChunk {412 func (v HistoryView) Chunk(i int) *HistoryChunk {
280 if i < 0 || i >= len(v.chunks) {413 if i < 0 || i >= len(v.chunks) {
@@ -316,6 +449,48 @@ func (v HistoryView) BorrowedRow(i int) []renderer.Cell {
316 return nil449 return nil
317 }450 }
318 451
452+// RowID returns the identity of the row at i, or zero when i is out of range.
453+func (v HistoryView) RowID(i int) RowID {
454+ if i < 0 {
455+ return 0
456+ }
457+ for _, chunk := range v.chunks {
458+ if i < len(chunk.rows) {
459+ if i < len(chunk.rowIDs) {
460+ return chunk.rowIDs[i]
461+ }
462+ return 0
463+ }
464+ i -= len(chunk.rows)
465+ }
466+ return 0
467+}
468+
469+// FindRowID returns the oldest-first index of id, or -1 when id is absent.
470+func (v HistoryView) FindRowID(id RowID) int {
471+ if id == 0 {
472+ return -1
473+ }
474+ index := 0
475+ for _, chunk := range v.chunks {
476+ for row := range chunk.rows {
477+ if row < len(chunk.rowIDs) && chunk.rowIDs[row] == id {
478+ return index
479+ }
480+ index++
481+ }
482+ }
483+ return -1
484+}
485+
486+// RowID returns the identity of the row within an immutable chunk.
487+func (c *HistoryChunk) RowID(i int) RowID {
488+ if c == nil || i < 0 || i >= len(c.rowIDs) {
489+ return 0
490+ }
491+ return c.rowIDs[i]
492+}
493+
319 // Bound returns the logical extent of the row at i, or the zero value when i is494 // 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.495 // out of range. A zero value describes a hard row whose End is not meaningful.
321 func (v HistoryView) Bound(i int) LineBound {496 func (v HistoryView) Bound(i int) LineBound {
@@ -338,6 +513,14 @@ func (v HistorySnapshotView) Len() int { return v.rows }
338 func (v HistorySnapshotView) Cells() int { return v.cells }513 func (v HistorySnapshotView) Cells() int { return v.cells }
339 func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) }514 func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) }
340 515
516+// NextRowID returns the next identity recorded by this snapshot.
517+func (v HistorySnapshotView) NextRowID() RowID {
518+ if v.nextRowID == 0 {
519+ return v.Tail().NextRowID()
520+ }
521+ return v.nextRowID
522+}
523+
341 // Chunk returns a sealed immutable chunk at i, or nil when i is out of range.524 // Chunk returns a sealed immutable chunk at i, or nil when i is out of range.
342 func (v HistorySnapshotView) Chunk(i int) *HistoryChunk {525 func (v HistorySnapshotView) Chunk(i int) *HistoryChunk {
343 if i < 0 || i >= len(v.chunks) {526 if i < 0 || i >= len(v.chunks) {
@@ -349,12 +532,13 @@ func (v HistorySnapshotView) Chunk(i int) *HistoryChunk {
349 // Tail returns an immutable view of the copied mutable tail.532 // Tail returns an immutable view of the copied mutable tail.
350 func (v HistorySnapshotView) Tail() HistoryView {533 func (v HistorySnapshotView) Tail() HistoryView {
351 if len(v.tail) == 0 {534 if len(v.tail) == 0 {
352- return HistoryView{}535+ return HistoryView{nextRowID: v.nextRowID}
353 }536 }
354 return HistoryView{537 return HistoryView{
355- chunks: []*HistoryChunk{{rows: v.tail, bounds: growBounds(v.tailBounds, len(v.tail))}},538+ chunks: []*HistoryChunk{{rows: v.tail, bounds: growBounds(v.tailBounds, len(v.tail)), rowIDs: growRowIDs(v.tailIDs, len(v.tail))}},
356- rows: len(v.tail),539+ rows: len(v.tail),
357- cells: historyRowsCells(v.tail),540+ cells: historyRowsCells(v.tail),
541+ nextRowID: v.nextRowID,
358 }542 }
359 }543 }
360 544
M pkg/vt/history_codec.go +116 -23
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "fmt"
77 "math"
8+ "slices"
89 "unicode/utf8"
910
1011 "github.com/bnema/vev/pkg/renderer"
@@ -12,7 +13,7 @@ import (
1213
1314 const (
1415 historyMagic = "VTH1"
15- historyVersion = 2
16+ historyVersion = 3
1617 historyCellBytes = 41
1718 historyBoundBytes = 5
1819 maxHistoryChunkRows = 256
@@ -37,13 +38,29 @@ func MarshalHistory(view HistoryView) ([]byte, error) {
3738 if view.rows < 0 || view.rows != historyViewRowCount(view) || uint64(len(view.chunks)) > math.MaxUint32 || uint64(len(view.chunks)) > maxHistoryChunks {
3839 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
3940 }
41+ nextRowID, ok := historyViewNextRowID(view)
42+ if !ok {
43+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
44+ }
4045 stats := historyDecodeStats{}
46+ seen := make(map[RowID]struct{}, view.rows)
4147 for _, chunk := range view.chunks {
42- if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) > maxHistoryChunkRows ||
48+ if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) != len(chunk.rowIDs) || len(chunk.rows) > maxHistoryChunkRows ||
4349 !addHistoryDecodeBudget(&stats.rows, uint64(len(chunk.rows)), maxHistoryRows) {
4450 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
4551 }
46- for _, row := range chunk.rows {
52+ for i, row := range chunk.rows {
53+ if uint64(len(row)) > math.MaxUint32 {
54+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
55+ }
56+ id := chunk.rowIDs[i]
57+ if id == 0 || id >= ^RowID(0)-1 {
58+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
59+ }
60+ if _, duplicate := seen[id]; duplicate {
61+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
62+ }
63+ seen[id] = struct{}{}
4764 cellCount := uint64(len(row))
4865 cellBytes, ok := historyCellByteCount(cellCount)
4966 if !ok ||
@@ -52,39 +69,80 @@ func MarshalHistory(view HistoryView) ([]byte, error) {
5269 !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) {
5370 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
5471 }
72+ bound := chunkBound(chunk, i)
73+ if !validHistoryBound(bound, len(row)) {
74+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
75+ }
76+ for _, cell := range row {
77+ if !validHistoryCell(cell) {
78+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
79+ }
80+ }
5581 }
5682 }
83+ if nextRowID <= maxHistoryRowID(seen) {
84+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
85+ }
5786
58- out := make([]byte, 0, 9)
87+ out := make([]byte, 0, 17)
5988 out = append(out, historyMagic...)
6089 out = append(out, historyVersion)
6190 out = appendUint32(out, uint32(len(view.chunks)))
91+ out = binary.BigEndian.AppendUint64(out, uint64(nextRowID))
6292 for _, chunk := range view.chunks {
63- if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) > maxHistoryChunkRows {
64- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
65- }
6693 out = appendUint32(out, uint32(len(chunk.rows)))
6794 for i, row := range chunk.rows {
68- if uint64(len(row)) > math.MaxUint32 {
69- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
70- }
71- bound := chunkBound(chunk, i)
72- if !validHistoryBound(bound, len(row)) {
73- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
74- }
95+ out = binary.BigEndian.AppendUint64(out, uint64(chunk.rowIDs[i]))
7596 out = appendUint32(out, uint32(len(row)))
7697 for _, cell := range row {
77- if !validHistoryCell(cell) {
78- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
79- }
8098 out = appendHistoryCell(out, cell)
8199 }
82- out = appendHistoryBound(out, bound)
100+ out = appendHistoryBound(out, chunkBound(chunk, i))
83101 }
84102 }
85103 return out, nil
86104 }
87105
106+func historyViewNextRowID(view HistoryView) (RowID, bool) {
107+ seen := make(map[RowID]struct{}, view.rows)
108+ for _, chunk := range view.chunks {
109+ if chunk == nil || len(chunk.rows) != len(chunk.rowIDs) {
110+ return 0, false
111+ }
112+ for _, id := range chunk.rowIDs {
113+ if id == 0 || id >= ^RowID(0)-1 {
114+ return 0, false
115+ }
116+ if _, duplicate := seen[id]; duplicate {
117+ return 0, false
118+ }
119+ seen[id] = struct{}{}
120+ }
121+ }
122+ next := view.nextRowID
123+ if next == 0 {
124+ maxID := maxHistoryRowID(seen)
125+ if maxID == ^RowID(0) {
126+ return 0, false
127+ }
128+ next = maxID + 1
129+ }
130+ if next == 0 || next == ^RowID(0) || next <= maxHistoryRowID(seen) {
131+ return 0, false
132+ }
133+ return next, true
134+}
135+
136+func maxHistoryRowID(ids map[RowID]struct{}) RowID {
137+ var maxID RowID
138+ for id := range ids {
139+ if id > maxID {
140+ maxID = id
141+ }
142+ }
143+ return maxID
144+}
145+
88146 func historyViewRowCount(view HistoryView) int {
89147 n := 0
90148 for _, chunk := range view.chunks {
@@ -189,7 +247,7 @@ func preflightHistory(data []byte) (historyDecodeStats, bool) {
189247 // parseHistory is the authoritative history payload validation path. populate
190248 // controls whether validated cells are retained in a HistoryView.
191249 func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, bool) {
192- if len(data) < 9 || string(data[:4]) != historyMagic || data[4] != historyVersion {
250+ if len(data) < 17 || string(data[:4]) != historyMagic || data[4] != historyVersion {
193251 return HistoryView{}, historyDecodeStats{}, false
194252 }
195253
@@ -198,7 +256,14 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
198256 if !ok || uint64(chunkCount) > maxHistoryChunks || uint64(chunkCount) > uint64(len(p.data))/4 {
199257 return HistoryView{}, historyDecodeStats{}, false
200258 }
259+ nextRowID, ok := p.uint64()
260+ if !ok || nextRowID == 0 || nextRowID == uint64(^RowID(0)) {
261+ return HistoryView{}, historyDecodeStats{}, false
262+ }
201263 stats := historyDecodeStats{chunks: uint64(chunkCount)}
264+ var seenIDs [maxHistoryRows]RowID
265+ seenCount := 0
266+ var maxID RowID
202267 var chunks []*HistoryChunk
203268 if populate {
204269 chunks = make([]*HistoryChunk, 0, chunkCount)
@@ -206,18 +271,30 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
206271 for range chunkCount {
207272 rowCount, ok := p.uint32()
208273 if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows ||
209- uint64(rowCount) > uint64(len(p.data))/(4+historyBoundBytes) ||
274+ uint64(rowCount) > uint64(len(p.data))/(8+4+historyBoundBytes) ||
210275 !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) {
211276 return HistoryView{}, historyDecodeStats{}, false
212277 }
213278
214279 var rows [][]renderer.Cell
215280 var bounds []LineBound
281+ var rowIDs []RowID
216282 if populate {
217283 rows = make([][]renderer.Cell, 0, rowCount)
218284 bounds = make([]LineBound, 0, rowCount)
285+ rowIDs = make([]RowID, 0, rowCount)
219286 }
220287 for range rowCount {
288+ id, ok := p.uint64()
289+ if !ok || id == 0 || id >= uint64(^RowID(0)-1) {
290+ return HistoryView{}, historyDecodeStats{}, false
291+ }
292+ rowID := RowID(id)
293+ seenIDs[seenCount] = rowID
294+ seenCount++
295+ if rowID > maxID {
296+ maxID = rowID
297+ }
221298 cellCount, ok := p.uint32()
222299 cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount))
223300 if !ok || !validCellBytes ||
@@ -248,16 +325,23 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
248325 if populate {
249326 rows = append(rows, row)
250327 bounds = append(bounds, bound)
328+ rowIDs = append(rowIDs, rowID)
251329 }
252330 }
253331 if populate {
254- chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds})
332+ chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds, rowIDs: rowIDs})
255333 }
256334 }
257- if len(p.data) != 0 {
335+ slices.Sort(seenIDs[:seenCount])
336+ for i := 1; i < seenCount; i++ {
337+ if seenIDs[i] == seenIDs[i-1] {
338+ return HistoryView{}, historyDecodeStats{}, false
339+ }
340+ }
341+ if len(p.data) != 0 || RowID(nextRowID) <= maxID {
258342 return HistoryView{}, historyDecodeStats{}, false
259343 }
260- return HistoryView{chunks: chunks, rows: int(stats.rows), cells: int(stats.cells)}, stats, true
344+ return HistoryView{chunks: chunks, rows: int(stats.rows), cells: int(stats.cells), nextRowID: RowID(nextRowID)}, stats, true
261345 }
262346
263347 func historyCellByteCount(cellCount uint64) (uint64, bool) {
@@ -286,6 +370,15 @@ func (p *historyParser) uint32() (uint32, bool) {
286370 return v, true
287371 }
288372
373+func (p *historyParser) uint64() (uint64, bool) {
374+ if len(p.data) < 8 {
375+ return 0, false
376+ }
377+ v := binary.BigEndian.Uint64(p.data)
378+ p.data = p.data[8:]
379+ return v, true
380+}
381+
289382 func (p *historyParser) cell() (renderer.Cell, bool) {
290383 if len(p.data) < historyCellBytes {
291384 return renderer.Cell{}, false
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7 "math"7 "math"
8+ "slices"
8 "unicode/utf8"9 "unicode/utf8"
9 10
10 "github.com/bnema/vev/pkg/renderer"11 "github.com/bnema/vev/pkg/renderer"
@@ -12,7 +13,7 @@ import (
12 13
13 const (14 const (
14 historyMagic = "VTH1"15 historyMagic = "VTH1"
15- historyVersion = 216+ historyVersion = 3
16 historyCellBytes = 4117 historyCellBytes = 41
17 historyBoundBytes = 518 historyBoundBytes = 5
18 maxHistoryChunkRows = 25619 maxHistoryChunkRows = 256
@@ -37,13 +38,29 @@ func MarshalHistory(view HistoryView) ([]byte, error) {
37 if view.rows < 0 || view.rows != historyViewRowCount(view) || uint64(len(view.chunks)) > math.MaxUint32 || uint64(len(view.chunks)) > maxHistoryChunks {38 if view.rows < 0 || view.rows != historyViewRowCount(view) || uint64(len(view.chunks)) > math.MaxUint32 || uint64(len(view.chunks)) > maxHistoryChunks {
38 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)39 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
39 }40 }
41+ nextRowID, ok := historyViewNextRowID(view)
42+ if !ok {
43+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
44+ }
40 stats := historyDecodeStats{}45 stats := historyDecodeStats{}
46+ seen := make(map[RowID]struct{}, view.rows)
41 for _, chunk := range view.chunks {47 for _, chunk := range view.chunks {
42- if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) > maxHistoryChunkRows ||48+ if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) != len(chunk.rowIDs) || len(chunk.rows) > maxHistoryChunkRows ||
43 !addHistoryDecodeBudget(&stats.rows, uint64(len(chunk.rows)), maxHistoryRows) {49 !addHistoryDecodeBudget(&stats.rows, uint64(len(chunk.rows)), maxHistoryRows) {
44 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)50 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
45 }51 }
46- for _, row := range chunk.rows {52+ for i, row := range chunk.rows {
53+ if uint64(len(row)) > math.MaxUint32 {
54+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
55+ }
56+ id := chunk.rowIDs[i]
57+ if id == 0 || id >= ^RowID(0)-1 {
58+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
59+ }
60+ if _, duplicate := seen[id]; duplicate {
61+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
62+ }
63+ seen[id] = struct{}{}
47 cellCount := uint64(len(row))64 cellCount := uint64(len(row))
48 cellBytes, ok := historyCellByteCount(cellCount)65 cellBytes, ok := historyCellByteCount(cellCount)
49 if !ok ||66 if !ok ||
@@ -52,39 +69,80 @@ func MarshalHistory(view HistoryView) ([]byte, error) {
52 !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) {69 !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) {
53 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)70 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
54 }71 }
72+ bound := chunkBound(chunk, i)
73+ if !validHistoryBound(bound, len(row)) {
74+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
75+ }
76+ for _, cell := range row {
77+ if !validHistoryCell(cell) {
78+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
79+ }
80+ }
55 }81 }
56 }82 }
83+ if nextRowID <= maxHistoryRowID(seen) {
84+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
85+ }
57 86
58- out := make([]byte, 0, 9)87+ out := make([]byte, 0, 17)
59 out = append(out, historyMagic...)88 out = append(out, historyMagic...)
60 out = append(out, historyVersion)89 out = append(out, historyVersion)
61 out = appendUint32(out, uint32(len(view.chunks)))90 out = appendUint32(out, uint32(len(view.chunks)))
91+ out = binary.BigEndian.AppendUint64(out, uint64(nextRowID))
62 for _, chunk := range view.chunks {92 for _, chunk := range view.chunks {
63- if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) > maxHistoryChunkRows {
64- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
65- }
66 out = appendUint32(out, uint32(len(chunk.rows)))93 out = appendUint32(out, uint32(len(chunk.rows)))
67 for i, row := range chunk.rows {94 for i, row := range chunk.rows {
68- if uint64(len(row)) > math.MaxUint32 {95+ out = binary.BigEndian.AppendUint64(out, uint64(chunk.rowIDs[i]))
69- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
70- }
71- bound := chunkBound(chunk, i)
72- if !validHistoryBound(bound, len(row)) {
73- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
74- }
75 out = appendUint32(out, uint32(len(row)))96 out = appendUint32(out, uint32(len(row)))
76 for _, cell := range row {97 for _, cell := range row {
77- if !validHistoryCell(cell) {
78- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
79- }
80 out = appendHistoryCell(out, cell)98 out = appendHistoryCell(out, cell)
81 }99 }
82- out = appendHistoryBound(out, bound)100+ out = appendHistoryBound(out, chunkBound(chunk, i))
83 }101 }
84 }102 }
85 return out, nil103 return out, nil
86 }104 }
87 105
106+func historyViewNextRowID(view HistoryView) (RowID, bool) {
107+ seen := make(map[RowID]struct{}, view.rows)
108+ for _, chunk := range view.chunks {
109+ if chunk == nil || len(chunk.rows) != len(chunk.rowIDs) {
110+ return 0, false
111+ }
112+ for _, id := range chunk.rowIDs {
113+ if id == 0 || id >= ^RowID(0)-1 {
114+ return 0, false
115+ }
116+ if _, duplicate := seen[id]; duplicate {
117+ return 0, false
118+ }
119+ seen[id] = struct{}{}
120+ }
121+ }
122+ next := view.nextRowID
123+ if next == 0 {
124+ maxID := maxHistoryRowID(seen)
125+ if maxID == ^RowID(0) {
126+ return 0, false
127+ }
128+ next = maxID + 1
129+ }
130+ if next == 0 || next == ^RowID(0) || next <= maxHistoryRowID(seen) {
131+ return 0, false
132+ }
133+ return next, true
134+}
135+
136+func maxHistoryRowID(ids map[RowID]struct{}) RowID {
137+ var maxID RowID
138+ for id := range ids {
139+ if id > maxID {
140+ maxID = id
141+ }
142+ }
143+ return maxID
144+}
145+
88 func historyViewRowCount(view HistoryView) int {146 func historyViewRowCount(view HistoryView) int {
89 n := 0147 n := 0
90 for _, chunk := range view.chunks {148 for _, chunk := range view.chunks {
@@ -189,7 +247,7 @@ func preflightHistory(data []byte) (historyDecodeStats, bool) {
189 // parseHistory is the authoritative history payload validation path. populate247 // parseHistory is the authoritative history payload validation path. populate
190 // controls whether validated cells are retained in a HistoryView.248 // controls whether validated cells are retained in a HistoryView.
191 func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, bool) {249 func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, bool) {
192- if len(data) < 9 || string(data[:4]) != historyMagic || data[4] != historyVersion {250+ if len(data) < 17 || string(data[:4]) != historyMagic || data[4] != historyVersion {
193 return HistoryView{}, historyDecodeStats{}, false251 return HistoryView{}, historyDecodeStats{}, false
194 }252 }
195 253
@@ -198,7 +256,14 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
198 if !ok || uint64(chunkCount) > maxHistoryChunks || uint64(chunkCount) > uint64(len(p.data))/4 {256 if !ok || uint64(chunkCount) > maxHistoryChunks || uint64(chunkCount) > uint64(len(p.data))/4 {
199 return HistoryView{}, historyDecodeStats{}, false257 return HistoryView{}, historyDecodeStats{}, false
200 }258 }
259+ nextRowID, ok := p.uint64()
260+ if !ok || nextRowID == 0 || nextRowID == uint64(^RowID(0)) {
261+ return HistoryView{}, historyDecodeStats{}, false
262+ }
201 stats := historyDecodeStats{chunks: uint64(chunkCount)}263 stats := historyDecodeStats{chunks: uint64(chunkCount)}
264+ var seenIDs [maxHistoryRows]RowID
265+ seenCount := 0
266+ var maxID RowID
202 var chunks []*HistoryChunk267 var chunks []*HistoryChunk
203 if populate {268 if populate {
204 chunks = make([]*HistoryChunk, 0, chunkCount)269 chunks = make([]*HistoryChunk, 0, chunkCount)
@@ -206,18 +271,30 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
206 for range chunkCount {271 for range chunkCount {
207 rowCount, ok := p.uint32()272 rowCount, ok := p.uint32()
208 if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows ||273 if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows ||
209- uint64(rowCount) > uint64(len(p.data))/(4+historyBoundBytes) ||274+ uint64(rowCount) > uint64(len(p.data))/(8+4+historyBoundBytes) ||
210 !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) {275 !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) {
211 return HistoryView{}, historyDecodeStats{}, false276 return HistoryView{}, historyDecodeStats{}, false
212 }277 }
213 278
214 var rows [][]renderer.Cell279 var rows [][]renderer.Cell
215 var bounds []LineBound280 var bounds []LineBound
281+ var rowIDs []RowID
216 if populate {282 if populate {
217 rows = make([][]renderer.Cell, 0, rowCount)283 rows = make([][]renderer.Cell, 0, rowCount)
218 bounds = make([]LineBound, 0, rowCount)284 bounds = make([]LineBound, 0, rowCount)
285+ rowIDs = make([]RowID, 0, rowCount)
219 }286 }
220 for range rowCount {287 for range rowCount {
288+ id, ok := p.uint64()
289+ if !ok || id == 0 || id >= uint64(^RowID(0)-1) {
290+ return HistoryView{}, historyDecodeStats{}, false
291+ }
292+ rowID := RowID(id)
293+ seenIDs[seenCount] = rowID
294+ seenCount++
295+ if rowID > maxID {
296+ maxID = rowID
297+ }
221 cellCount, ok := p.uint32()298 cellCount, ok := p.uint32()
222 cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount))299 cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount))
223 if !ok || !validCellBytes ||300 if !ok || !validCellBytes ||
@@ -248,16 +325,23 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
248 if populate {325 if populate {
249 rows = append(rows, row)326 rows = append(rows, row)
250 bounds = append(bounds, bound)327 bounds = append(bounds, bound)
328+ rowIDs = append(rowIDs, rowID)
251 }329 }
252 }330 }
253 if populate {331 if populate {
254- chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds})332+ chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds, rowIDs: rowIDs})
255 }333 }
256 }334 }
257- if len(p.data) != 0 {335+ slices.Sort(seenIDs[:seenCount])
336+ for i := 1; i < seenCount; i++ {
337+ if seenIDs[i] == seenIDs[i-1] {
338+ return HistoryView{}, historyDecodeStats{}, false
339+ }
340+ }
341+ if len(p.data) != 0 || RowID(nextRowID) <= maxID {
258 return HistoryView{}, historyDecodeStats{}, false342 return HistoryView{}, historyDecodeStats{}, false
259 }343 }
260- return HistoryView{chunks: chunks, rows: int(stats.rows), cells: int(stats.cells)}, stats, true344+ return HistoryView{chunks: chunks, rows: int(stats.rows), cells: int(stats.cells), nextRowID: RowID(nextRowID)}, stats, true
261 }345 }
262 346
263 func historyCellByteCount(cellCount uint64) (uint64, bool) {347 func historyCellByteCount(cellCount uint64) (uint64, bool) {
@@ -286,6 +370,15 @@ func (p *historyParser) uint32() (uint32, bool) {
286 return v, true370 return v, true
287 }371 }
288 372
373+func (p *historyParser) uint64() (uint64, bool) {
374+ if len(p.data) < 8 {
375+ return 0, false
376+ }
377+ v := binary.BigEndian.Uint64(p.data)
378+ p.data = p.data[8:]
379+ return v, true
380+}
381+
289 func (p *historyParser) cell() (renderer.Cell, bool) {382 func (p *historyParser) cell() (renderer.Cell, bool) {
290 if len(p.data) < historyCellBytes {383 if len(p.data) < historyCellBytes {
291 return renderer.Cell{}, false384 return renderer.Cell{}, false
M pkg/vt/history_codec_bound_test.go +4 -3
@@ -10,9 +10,10 @@ import (
1010
1111 func TestHistoryCodecRejectsMalformedBounds(t *testing.T) {
1212 blob, err := MarshalHistory(HistoryView{
13- chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("abcd")}, bounds: []LineBound{{End: 4, Soft: true}}}},
14- rows: 1,
15- cells: 4,
13+ chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("abcd")}, bounds: []LineBound{{End: 4, Soft: true}}, rowIDs: []RowID{1}}},
14+ rows: 1,
15+ cells: 4,
16+ nextRowID: 2,
1617 })
1718 require.NoError(t, err)
1819 boundOffset := len(blob) - historyBoundBytes
@@ -10,9 +10,10 @@ import (
10 10
11 func TestHistoryCodecRejectsMalformedBounds(t *testing.T) {11 func TestHistoryCodecRejectsMalformedBounds(t *testing.T) {
12 blob, err := MarshalHistory(HistoryView{12 blob, err := MarshalHistory(HistoryView{
13- chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("abcd")}, bounds: []LineBound{{End: 4, Soft: true}}}},13+ chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("abcd")}, bounds: []LineBound{{End: 4, Soft: true}}, rowIDs: []RowID{1}}},
14- rows: 1,14+ rows: 1,
15- cells: 4,15+ cells: 4,
16+ nextRowID: 2,
16 })17 })
17 require.NoError(t, err)18 require.NoError(t, err)
18 boundOffset := len(blob) - historyBoundBytes19 boundOffset := len(blob) - historyBoundBytes
M pkg/vt/history_codec_test.go +45 -45
@@ -10,7 +10,7 @@ import (
1010 "github.com/bnema/vev/pkg/renderer"
1111 )
1212
13-func TestHistoryCodecV2Bytes(t *testing.T) {
13+func TestHistoryCodecV3Bytes(t *testing.T) {
1414 h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2})
1515 if err := h.Append([]renderer.Cell{{Rune: 'a'}}, LineBound{End: 1, Soft: true}); err != nil {
1616 t.Fatalf("append: %v", err)
@@ -21,9 +21,11 @@ func TestHistoryCodecV2Bytes(t *testing.T) {
2121 }
2222
2323 want := []byte{
24- 'V', 'T', 'H', '1', 2, // magic, version
24+ 'V', 'T', 'H', '1', 3, // magic, version
2525 0, 0, 0, 1, // chunk count
26+ 0, 0, 0, 0, 0, 0, 0, 2, // next row ID
2627 0, 0, 0, 1, // row count
28+ 0, 0, 0, 0, 0, 0, 0, 1, // row ID
2729 0, 0, 0, 1, // cell count
2830 0, 0, 0, 'a', // Rune
2931 0, // flags
@@ -65,12 +67,12 @@ func TestHistoryCodecRoundTripsBounds(t *testing.T) {
6567 }
6668 }
6769
68-func TestHistoryCodecRejectsMalformedAndV1Payloads(t *testing.T) {
69- // Canonical v2 fixture from TestHistoryCodecV2Bytes.
70- v2 := []byte{
70+func TestHistoryCodecRejectsLegacyPayloads(t *testing.T) {
71+ // The previous layouts are deliberately incompatible with stable IDs.
72+ legacy := []byte{
7173 'V', 'T', 'H', '1', 2,
72- 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1,
73- 0, 0, 0, 'a',
74+ 0, 0, 0, 1, 0, 0, 0, 1,
75+ 0, 0, 0, 1, 0, 0, 0, 'a',
7476 0, 0, 0,
7577 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
7678 0, 0, 0, 0, 0, 0,
@@ -79,23 +81,13 @@ func TestHistoryCodecRejectsMalformedAndV1Payloads(t *testing.T) {
7981 0, 0, 0,
8082 0, 0, 0, 1, 1,
8183 }
82-
83- // This is a genuine v1-layout payload: version 1 and no trailing bound
84- // bytes. It is not a v2 payload with only its version byte changed.
85- v1 := append([]byte(nil), v2[:len(v2)-historyBoundBytes]...)
86- v1[4] = 1
87- if _, err := UnmarshalHistory(v1); err == nil {
88- t.Fatal("accepted a genuine v1 history payload")
89- }
90-
91- for i := 0; i < len(v2); i++ {
92- if _, err := UnmarshalHistory(v2[:i]); err == nil {
93- t.Fatalf("accepted truncated prefix of length %d", i)
84+ for _, version := range []byte{1, 2} {
85+ data := append([]byte(nil), legacy...)
86+ data[4] = version
87+ if _, err := UnmarshalHistory(data); err == nil {
88+ t.Fatalf("accepted legacy history version %d", version)
9489 }
9590 }
96- if _, err := UnmarshalHistory(append(append([]byte(nil), v2...), 0xff)); err == nil {
97- t.Fatal("accepted trailing garbage")
98- }
9991 }
10092
10193 func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) {
@@ -171,21 +163,13 @@ func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) {
171163 t.Fatalf("marshal history: %v", err)
172164 }
173165
174- tests := []struct {
175- name string
176- data []byte
177- }{
178- {name: "empty", data: nil},
179- {name: "truncated prefix", data: encoded[:len(encoded)-1]},
180- {name: "trailing byte", data: append(append([]byte(nil), encoded...), 0)},
166+ for i := range len(encoded) {
167+ if _, err := UnmarshalHistory(encoded[:i]); err == nil {
168+ t.Fatalf("accepted truncated version 3 prefix of length %d", i)
169+ }
181170 }
182-
183- for _, tt := range tests {
184- t.Run(tt.name, func(t *testing.T) {
185- if _, err := UnmarshalHistory(tt.data); err == nil {
186- t.Fatal("unmarshal accepted malformed history payload")
187- }
188- })
171+ if _, err := UnmarshalHistory(append(append([]byte(nil), encoded...), 0)); err == nil {
172+ t.Fatal("accepted trailing garbage after version 3 payload")
189173 }
190174 }
191175
@@ -300,31 +284,41 @@ func historyViewWithDimensions(rowCount, width int) HistoryView {
300284 row[i].Rune = 'x'
301285 }
302286 chunks := make([]*HistoryChunk, 0, (rowCount+maxHistoryChunkRows-1)/maxHistoryChunkRows)
287+ rowID := RowID(1)
303288 for rowCount > 0 {
304289 chunkRows := min(rowCount, maxHistoryChunkRows)
305290 rows := make([][]renderer.Cell, chunkRows)
291+ rowIDs := make([]RowID, chunkRows)
306292 for i := range rows {
307293 rows[i] = row
294+ rowIDs[i] = rowID
295+ rowID++
308296 }
309- chunks = append(chunks, &HistoryChunk{rows: rows})
297+ chunks = append(chunks, &HistoryChunk{rows: rows, rowIDs: rowIDs})
310298 rowCount -= chunkRows
311299 }
312- return HistoryView{chunks: chunks, rows: totalRows}
300+ return HistoryView{chunks: chunks, rows: totalRows, nextRowID: rowID}
313301 }
314302
315303 func historyPayloadWithDimensions(rowCount, width int) []byte {
316- data := make([]byte, 9)
304+ data := make([]byte, 17)
317305 copy(data, historyMagic)
318306 data[4] = historyVersion
319307 chunkCount := (rowCount + maxHistoryChunkRows - 1) / maxHistoryChunkRows
320308 binary.BigEndian.PutUint32(data[5:], uint32(chunkCount))
309+ binary.BigEndian.PutUint64(data[9:], uint64(rowCount+1))
310+ rowID := uint64(1)
321311 for rowCount > 0 {
322312 chunkRows := min(rowCount, maxHistoryChunkRows)
323313 data = binary.BigEndian.AppendUint32(data, uint32(chunkRows))
324314 for range chunkRows {
315+ data = binary.BigEndian.AppendUint64(data, rowID)
316+ rowID++
325317 data = binary.BigEndian.AppendUint32(data, uint32(width))
326318 //nolint:makezero // The header prefix is retained while zeroed cell records are appended.
327319 data = append(data, make([]byte, width*historyCellBytes)...)
320+ data = binary.BigEndian.AppendUint32(data, uint32(width))
321+ data = append(data, 0) //nolint:makezero // The header prefix is retained while the bound is appended.
328322 }
329323 rowCount -= chunkRows
330324 }
@@ -335,22 +329,28 @@ func historyPayloadWithDimensions(rowCount, width int) []byte {
335329 // budget, but its cells exceed the aggregate budget. It deliberately omits
336330 // cell payload bytes: the aggregate limit must reject it before allocation.
337331 func aggregateCellLimitDeclaration() []byte {
338- data := make([]byte, 9, 21)
332+ data := make([]byte, 17, 29)
339333 copy(data, historyMagic)
340334 data[4] = historyVersion
341335 binary.BigEndian.PutUint32(data[5:], 1)
336+ binary.BigEndian.PutUint64(data[9:], 2)
342337 data = binary.BigEndian.AppendUint32(data, 1)
338+ data = binary.BigEndian.AppendUint64(data, 1)
343339 return binary.BigEndian.AppendUint32(data, uint32(maxHistoryCells+1))
344340 }
345341
346342 func hostileHistoryDeclarations(chunkCount, rowCount int) []byte {
347- data := make([]byte, 9, 9+chunkCount*(4+rowCount*4))
343+ data := make([]byte, 17, 17+chunkCount*(4+rowCount*12))
348344 copy(data, historyMagic)
349345 data[4] = historyVersion
350346 binary.BigEndian.PutUint32(data[5:], uint32(chunkCount))
347+ binary.BigEndian.PutUint64(data[9:], uint64(chunkCount*rowCount+1))
348+ id := uint64(1)
351349 for range chunkCount {
352350 data = binary.BigEndian.AppendUint32(data, uint32(rowCount))
353351 for range rowCount {
352+ data = binary.BigEndian.AppendUint64(data, id)
353+ id++
354354 data = binary.BigEndian.AppendUint32(data, 0)
355355 }
356356 }
@@ -363,9 +363,9 @@ func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) {
363363 t.Fatalf("marshal valid history: %v", err)
364364 }
365365 invalidRune := append([]byte(nil), valid...)
366- binary.BigEndian.PutUint32(invalidRune[17:21], ^uint32(0))
366+ binary.BigEndian.PutUint32(invalidRune[33:37], ^uint32(0))
367367 invalidUnderlineStyle := append([]byte(nil), valid...)
368- invalidUnderlineStyle[17+29] = byte(renderer.UnderlineDashed + 1)
368+ invalidUnderlineStyle[33+29] = byte(renderer.UnderlineDashed + 1)
369369
370370 tests := []struct {
371371 name string
@@ -379,7 +379,7 @@ func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) {
379379 {name: "invalid underline style", data: invalidUnderlineStyle},
380380 {name: "aggregate row budget", data: hostileHistoryDeclarations(47, maxHistoryChunkRows)},
381381 {name: "aggregate cell budget", data: aggregateCellLimitDeclaration()},
382- {name: "zero row count", data: []byte{'V', 'T', 'H', '1', historyVersion, 0, 0, 0, 1, 0, 0, 0, 0}},
382+ {name: "zero row count", data: []byte{'V', 'T', 'H', '1', historyVersion, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0}},
383383 }
384384 for _, tt := range tests {
385385 t.Run(tt.name, func(t *testing.T) {
@@ -398,7 +398,7 @@ func FuzzHistoryPreflightMatchesUnmarshal(f *testing.F) {
398398 f.Fatalf("marshal valid history: %v", err)
399399 }
400400 invalidRune := append([]byte(nil), valid...)
401- binary.BigEndian.PutUint32(invalidRune[17:21], ^uint32(0))
401+ binary.BigEndian.PutUint32(invalidRune[33:37], ^uint32(0))
402402 f.Add(valid)
403403 f.Add(invalidRune)
404404 f.Add([]byte("VTH1\x01\x00\x00\x00\x00"))
@@ -10,7 +10,7 @@ import (
10 "github.com/bnema/vev/pkg/renderer"10 "github.com/bnema/vev/pkg/renderer"
11 )11 )
12 12
13-func TestHistoryCodecV2Bytes(t *testing.T) {13+func TestHistoryCodecV3Bytes(t *testing.T) {
14 h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2})14 h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2})
15 if err := h.Append([]renderer.Cell{{Rune: 'a'}}, LineBound{End: 1, Soft: true}); err != nil {15 if err := h.Append([]renderer.Cell{{Rune: 'a'}}, LineBound{End: 1, Soft: true}); err != nil {
16 t.Fatalf("append: %v", err)16 t.Fatalf("append: %v", err)
@@ -21,9 +21,11 @@ func TestHistoryCodecV2Bytes(t *testing.T) {
21 }21 }
22 22
23 want := []byte{23 want := []byte{
24- 'V', 'T', 'H', '1', 2, // magic, version24+ 'V', 'T', 'H', '1', 3, // magic, version
25 0, 0, 0, 1, // chunk count25 0, 0, 0, 1, // chunk count
26+ 0, 0, 0, 0, 0, 0, 0, 2, // next row ID
26 0, 0, 0, 1, // row count27 0, 0, 0, 1, // row count
28+ 0, 0, 0, 0, 0, 0, 0, 1, // row ID
27 0, 0, 0, 1, // cell count29 0, 0, 0, 1, // cell count
28 0, 0, 0, 'a', // Rune30 0, 0, 0, 'a', // Rune
29 0, // flags31 0, // flags
@@ -65,12 +67,12 @@ func TestHistoryCodecRoundTripsBounds(t *testing.T) {
65 }67 }
66 }68 }
67 69
68-func TestHistoryCodecRejectsMalformedAndV1Payloads(t *testing.T) {70+func TestHistoryCodecRejectsLegacyPayloads(t *testing.T) {
69- // Canonical v2 fixture from TestHistoryCodecV2Bytes.71+ // The previous layouts are deliberately incompatible with stable IDs.
70- v2 := []byte{72+ legacy := []byte{
71 'V', 'T', 'H', '1', 2,73 'V', 'T', 'H', '1', 2,
72- 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1,74+ 0, 0, 0, 1, 0, 0, 0, 1,
73- 0, 0, 0, 'a',75+ 0, 0, 0, 1, 0, 0, 0, 'a',
74 0, 0, 0,76 0, 0, 0,
75 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,77 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
76 0, 0, 0, 0, 0, 0,78 0, 0, 0, 0, 0, 0,
@@ -79,23 +81,13 @@ func TestHistoryCodecRejectsMalformedAndV1Payloads(t *testing.T) {
79 0, 0, 0,81 0, 0, 0,
80 0, 0, 0, 1, 1,82 0, 0, 0, 1, 1,
81 }83 }
82-84+ for _, version := range []byte{1, 2} {
83- // This is a genuine v1-layout payload: version 1 and no trailing bound85+ data := append([]byte(nil), legacy...)
84- // bytes. It is not a v2 payload with only its version byte changed.86+ data[4] = version
85- v1 := append([]byte(nil), v2[:len(v2)-historyBoundBytes]...)87+ if _, err := UnmarshalHistory(data); err == nil {
86- v1[4] = 188+ t.Fatalf("accepted legacy history version %d", version)
87- if _, err := UnmarshalHistory(v1); err == nil {
88- t.Fatal("accepted a genuine v1 history payload")
89- }
90-
91- for i := 0; i < len(v2); i++ {
92- if _, err := UnmarshalHistory(v2[:i]); err == nil {
93- t.Fatalf("accepted truncated prefix of length %d", i)
94 }89 }
95 }90 }
96- if _, err := UnmarshalHistory(append(append([]byte(nil), v2...), 0xff)); err == nil {
97- t.Fatal("accepted trailing garbage")
98- }
99 }91 }
100 92
101 func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) {93 func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) {
@@ -171,21 +163,13 @@ func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) {
171 t.Fatalf("marshal history: %v", err)163 t.Fatalf("marshal history: %v", err)
172 }164 }
173 165
174- tests := []struct {166+ for i := range len(encoded) {
175- name string167+ if _, err := UnmarshalHistory(encoded[:i]); err == nil {
176- data []byte168+ t.Fatalf("accepted truncated version 3 prefix of length %d", i)
177- }{169+ }
178- {name: "empty", data: nil},
179- {name: "truncated prefix", data: encoded[:len(encoded)-1]},
180- {name: "trailing byte", data: append(append([]byte(nil), encoded...), 0)},
181 }170 }
182-171+ if _, err := UnmarshalHistory(append(append([]byte(nil), encoded...), 0)); err == nil {
183- for _, tt := range tests {172+ t.Fatal("accepted trailing garbage after version 3 payload")
184- t.Run(tt.name, func(t *testing.T) {
185- if _, err := UnmarshalHistory(tt.data); err == nil {
186- t.Fatal("unmarshal accepted malformed history payload")
187- }
188- })
189 }173 }
190 }174 }
191 175
@@ -300,31 +284,41 @@ func historyViewWithDimensions(rowCount, width int) HistoryView {
300 row[i].Rune = 'x'284 row[i].Rune = 'x'
301 }285 }
302 chunks := make([]*HistoryChunk, 0, (rowCount+maxHistoryChunkRows-1)/maxHistoryChunkRows)286 chunks := make([]*HistoryChunk, 0, (rowCount+maxHistoryChunkRows-1)/maxHistoryChunkRows)
287+ rowID := RowID(1)
303 for rowCount > 0 {288 for rowCount > 0 {
304 chunkRows := min(rowCount, maxHistoryChunkRows)289 chunkRows := min(rowCount, maxHistoryChunkRows)
305 rows := make([][]renderer.Cell, chunkRows)290 rows := make([][]renderer.Cell, chunkRows)
291+ rowIDs := make([]RowID, chunkRows)
306 for i := range rows {292 for i := range rows {
307 rows[i] = row293 rows[i] = row
294+ rowIDs[i] = rowID
295+ rowID++
308 }296 }
309- chunks = append(chunks, &HistoryChunk{rows: rows})297+ chunks = append(chunks, &HistoryChunk{rows: rows, rowIDs: rowIDs})
310 rowCount -= chunkRows298 rowCount -= chunkRows
311 }299 }
312- return HistoryView{chunks: chunks, rows: totalRows}300+ return HistoryView{chunks: chunks, rows: totalRows, nextRowID: rowID}
313 }301 }
314 302
315 func historyPayloadWithDimensions(rowCount, width int) []byte {303 func historyPayloadWithDimensions(rowCount, width int) []byte {
316- data := make([]byte, 9)304+ data := make([]byte, 17)
317 copy(data, historyMagic)305 copy(data, historyMagic)
318 data[4] = historyVersion306 data[4] = historyVersion
319 chunkCount := (rowCount + maxHistoryChunkRows - 1) / maxHistoryChunkRows307 chunkCount := (rowCount + maxHistoryChunkRows - 1) / maxHistoryChunkRows
320 binary.BigEndian.PutUint32(data[5:], uint32(chunkCount))308 binary.BigEndian.PutUint32(data[5:], uint32(chunkCount))
309+ binary.BigEndian.PutUint64(data[9:], uint64(rowCount+1))
310+ rowID := uint64(1)
321 for rowCount > 0 {311 for rowCount > 0 {
322 chunkRows := min(rowCount, maxHistoryChunkRows)312 chunkRows := min(rowCount, maxHistoryChunkRows)
323 data = binary.BigEndian.AppendUint32(data, uint32(chunkRows))313 data = binary.BigEndian.AppendUint32(data, uint32(chunkRows))
324 for range chunkRows {314 for range chunkRows {
315+ data = binary.BigEndian.AppendUint64(data, rowID)
316+ rowID++
325 data = binary.BigEndian.AppendUint32(data, uint32(width))317 data = binary.BigEndian.AppendUint32(data, uint32(width))
326 //nolint:makezero // The header prefix is retained while zeroed cell records are appended.318 //nolint:makezero // The header prefix is retained while zeroed cell records are appended.
327 data = append(data, make([]byte, width*historyCellBytes)...)319 data = append(data, make([]byte, width*historyCellBytes)...)
320+ data = binary.BigEndian.AppendUint32(data, uint32(width))
321+ data = append(data, 0) //nolint:makezero // The header prefix is retained while the bound is appended.
328 }322 }
329 rowCount -= chunkRows323 rowCount -= chunkRows
330 }324 }
@@ -335,22 +329,28 @@ func historyPayloadWithDimensions(rowCount, width int) []byte {
335 // budget, but its cells exceed the aggregate budget. It deliberately omits329 // budget, but its cells exceed the aggregate budget. It deliberately omits
336 // cell payload bytes: the aggregate limit must reject it before allocation.330 // cell payload bytes: the aggregate limit must reject it before allocation.
337 func aggregateCellLimitDeclaration() []byte {331 func aggregateCellLimitDeclaration() []byte {
338- data := make([]byte, 9, 21)332+ data := make([]byte, 17, 29)
339 copy(data, historyMagic)333 copy(data, historyMagic)
340 data[4] = historyVersion334 data[4] = historyVersion
341 binary.BigEndian.PutUint32(data[5:], 1)335 binary.BigEndian.PutUint32(data[5:], 1)
336+ binary.BigEndian.PutUint64(data[9:], 2)
342 data = binary.BigEndian.AppendUint32(data, 1)337 data = binary.BigEndian.AppendUint32(data, 1)
338+ data = binary.BigEndian.AppendUint64(data, 1)
343 return binary.BigEndian.AppendUint32(data, uint32(maxHistoryCells+1))339 return binary.BigEndian.AppendUint32(data, uint32(maxHistoryCells+1))
344 }340 }
345 341
346 func hostileHistoryDeclarations(chunkCount, rowCount int) []byte {342 func hostileHistoryDeclarations(chunkCount, rowCount int) []byte {
347- data := make([]byte, 9, 9+chunkCount*(4+rowCount*4))343+ data := make([]byte, 17, 17+chunkCount*(4+rowCount*12))
348 copy(data, historyMagic)344 copy(data, historyMagic)
349 data[4] = historyVersion345 data[4] = historyVersion
350 binary.BigEndian.PutUint32(data[5:], uint32(chunkCount))346 binary.BigEndian.PutUint32(data[5:], uint32(chunkCount))
347+ binary.BigEndian.PutUint64(data[9:], uint64(chunkCount*rowCount+1))
348+ id := uint64(1)
351 for range chunkCount {349 for range chunkCount {
352 data = binary.BigEndian.AppendUint32(data, uint32(rowCount))350 data = binary.BigEndian.AppendUint32(data, uint32(rowCount))
353 for range rowCount {351 for range rowCount {
352+ data = binary.BigEndian.AppendUint64(data, id)
353+ id++
354 data = binary.BigEndian.AppendUint32(data, 0)354 data = binary.BigEndian.AppendUint32(data, 0)
355 }355 }
356 }356 }
@@ -363,9 +363,9 @@ func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) {
363 t.Fatalf("marshal valid history: %v", err)363 t.Fatalf("marshal valid history: %v", err)
364 }364 }
365 invalidRune := append([]byte(nil), valid...)365 invalidRune := append([]byte(nil), valid...)
366- binary.BigEndian.PutUint32(invalidRune[17:21], ^uint32(0))366+ binary.BigEndian.PutUint32(invalidRune[33:37], ^uint32(0))
367 invalidUnderlineStyle := append([]byte(nil), valid...)367 invalidUnderlineStyle := append([]byte(nil), valid...)
368- invalidUnderlineStyle[17+29] = byte(renderer.UnderlineDashed + 1)368+ invalidUnderlineStyle[33+29] = byte(renderer.UnderlineDashed + 1)
369 369
370 tests := []struct {370 tests := []struct {
371 name string371 name string
@@ -379,7 +379,7 @@ func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) {
379 {name: "invalid underline style", data: invalidUnderlineStyle},379 {name: "invalid underline style", data: invalidUnderlineStyle},
380 {name: "aggregate row budget", data: hostileHistoryDeclarations(47, maxHistoryChunkRows)},380 {name: "aggregate row budget", data: hostileHistoryDeclarations(47, maxHistoryChunkRows)},
381 {name: "aggregate cell budget", data: aggregateCellLimitDeclaration()},381 {name: "aggregate cell budget", data: aggregateCellLimitDeclaration()},
382- {name: "zero row count", data: []byte{'V', 'T', 'H', '1', historyVersion, 0, 0, 0, 1, 0, 0, 0, 0}},382+ {name: "zero row count", data: []byte{'V', 'T', 'H', '1', historyVersion, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0}},
383 }383 }
384 for _, tt := range tests {384 for _, tt := range tests {
385 t.Run(tt.name, func(t *testing.T) {385 t.Run(tt.name, func(t *testing.T) {
@@ -398,7 +398,7 @@ func FuzzHistoryPreflightMatchesUnmarshal(f *testing.F) {
398 f.Fatalf("marshal valid history: %v", err)398 f.Fatalf("marshal valid history: %v", err)
399 }399 }
400 invalidRune := append([]byte(nil), valid...)400 invalidRune := append([]byte(nil), valid...)
401- binary.BigEndian.PutUint32(invalidRune[17:21], ^uint32(0))401+ binary.BigEndian.PutUint32(invalidRune[33:37], ^uint32(0))
402 f.Add(valid)402 f.Add(valid)
403 f.Add(invalidRune)403 f.Add(invalidRune)
404 f.Add([]byte("VTH1\x01\x00\x00\x00\x00"))404 f.Add([]byte("VTH1\x01\x00\x00\x00\x00"))
M pkg/vt/recovery_transcript.go +12 -3
@@ -5,12 +5,14 @@ import "github.com/bnema/vev/pkg/renderer"
55 // RecoveryTranscriptSnapshot is an owned, immutable capture of the viewport
66 // rows that should be replayed after retained terminal history during recovery.
77 type RecoveryTranscriptSnapshot struct {
8- segments []recoveryTranscriptSegment
8+ segments []recoveryTranscriptSegment
9+ nextRowID RowID
910 }
1011
1112 type recoveryTranscriptSegment struct {
1213 rows [][]renderer.Cell
1314 bounds []LineBound
15+ rowIDs []RowID
1416 }
1517
1618 // RecoveryTranscriptSnapshot captures the active primary viewport, or the
@@ -25,7 +27,11 @@ func (s *Screen) RecoveryTranscriptSnapshot() RecoveryTranscriptSnapshot {
2527 buffers = []*buffer{s.alternate.buffer, s.buffer}
2628 }
2729
28- snapshot := RecoveryTranscriptSnapshot{segments: make([]recoveryTranscriptSegment, 0, len(buffers))}
30+ nextRowID := s.nextRowID
31+ if nextRowID < ^RowID(0) {
32+ nextRowID++
33+ }
34+ snapshot := RecoveryTranscriptSnapshot{segments: make([]recoveryTranscriptSegment, 0, len(buffers)), nextRowID: nextRowID}
2935 for _, b := range buffers {
3036 segment := captureRecoveryTranscriptSegment(b)
3137 if len(segment.rows) > 0 {
@@ -51,6 +57,7 @@ func captureRecoveryTranscriptSegment(b *buffer) recoveryTranscriptSegment {
5157 segment := recoveryTranscriptSegment{
5258 rows: make([][]renderer.Cell, rowCount),
5359 bounds: append([]LineBound(nil), b.boundaries[:rowCount]...),
60+ rowIDs: append([]RowID(nil), b.rowIDs[:rowCount]...),
5461 }
5562 cells := make([]renderer.Cell, rowCount*b.frame.Width)
5663 for y := range rowCount {
@@ -88,7 +95,7 @@ func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) {
8895 }
8996 }
9097
91- view := HistoryView{rows: rows, cells: cells}
98+ view := HistoryView{rows: rows, cells: cells, nextRowID: snapshot.nextRowID}
9299 for _, segment := range snapshot.segments {
93100 for start := 0; start < len(segment.rows); {
94101 space := maxHistoryChunkRows
@@ -99,6 +106,7 @@ func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) {
99106 end := min(start+space, len(segment.rows))
100107 last.rows = append(last.rows, segment.rows[start:end]...)
101108 last.bounds = append(last.bounds, segment.bounds[start:end]...)
109+ last.rowIDs = append(last.rowIDs, segment.rowIDs[start:end]...)
102110 start = end
103111 continue
104112 }
@@ -108,6 +116,7 @@ func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) {
108116 view.chunks = append(view.chunks, &HistoryChunk{
109117 rows: append([][]renderer.Cell(nil), segment.rows[start:end]...),
110118 bounds: append([]LineBound(nil), segment.bounds[start:end]...),
119+ rowIDs: append([]RowID(nil), segment.rowIDs[start:end]...),
111120 })
112121 start = end
113122 }
@@ -5,12 +5,14 @@ import "github.com/bnema/vev/pkg/renderer"
5 // RecoveryTranscriptSnapshot is an owned, immutable capture of the viewport5 // RecoveryTranscriptSnapshot is an owned, immutable capture of the viewport
6 // rows that should be replayed after retained terminal history during recovery.6 // rows that should be replayed after retained terminal history during recovery.
7 type RecoveryTranscriptSnapshot struct {7 type RecoveryTranscriptSnapshot struct {
8- segments []recoveryTranscriptSegment8+ segments []recoveryTranscriptSegment
9+ nextRowID RowID
9 }10 }
10 11
11 type recoveryTranscriptSegment struct {12 type recoveryTranscriptSegment struct {
12 rows [][]renderer.Cell13 rows [][]renderer.Cell
13 bounds []LineBound14 bounds []LineBound
15+ rowIDs []RowID
14 }16 }
15 17
16 // RecoveryTranscriptSnapshot captures the active primary viewport, or the18 // RecoveryTranscriptSnapshot captures the active primary viewport, or the
@@ -25,7 +27,11 @@ func (s *Screen) RecoveryTranscriptSnapshot() RecoveryTranscriptSnapshot {
25 buffers = []*buffer{s.alternate.buffer, s.buffer}27 buffers = []*buffer{s.alternate.buffer, s.buffer}
26 }28 }
27 29
28- snapshot := RecoveryTranscriptSnapshot{segments: make([]recoveryTranscriptSegment, 0, len(buffers))}30+ nextRowID := s.nextRowID
31+ if nextRowID < ^RowID(0) {
32+ nextRowID++
33+ }
34+ snapshot := RecoveryTranscriptSnapshot{segments: make([]recoveryTranscriptSegment, 0, len(buffers)), nextRowID: nextRowID}
29 for _, b := range buffers {35 for _, b := range buffers {
30 segment := captureRecoveryTranscriptSegment(b)36 segment := captureRecoveryTranscriptSegment(b)
31 if len(segment.rows) > 0 {37 if len(segment.rows) > 0 {
@@ -51,6 +57,7 @@ func captureRecoveryTranscriptSegment(b *buffer) recoveryTranscriptSegment {
51 segment := recoveryTranscriptSegment{57 segment := recoveryTranscriptSegment{
52 rows: make([][]renderer.Cell, rowCount),58 rows: make([][]renderer.Cell, rowCount),
53 bounds: append([]LineBound(nil), b.boundaries[:rowCount]...),59 bounds: append([]LineBound(nil), b.boundaries[:rowCount]...),
60+ rowIDs: append([]RowID(nil), b.rowIDs[:rowCount]...),
54 }61 }
55 cells := make([]renderer.Cell, rowCount*b.frame.Width)62 cells := make([]renderer.Cell, rowCount*b.frame.Width)
56 for y := range rowCount {63 for y := range rowCount {
@@ -88,7 +95,7 @@ func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) {
88 }95 }
89 }96 }
90 97
91- view := HistoryView{rows: rows, cells: cells}98+ view := HistoryView{rows: rows, cells: cells, nextRowID: snapshot.nextRowID}
92 for _, segment := range snapshot.segments {99 for _, segment := range snapshot.segments {
93 for start := 0; start < len(segment.rows); {100 for start := 0; start < len(segment.rows); {
94 space := maxHistoryChunkRows101 space := maxHistoryChunkRows
@@ -99,6 +106,7 @@ func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) {
99 end := min(start+space, len(segment.rows))106 end := min(start+space, len(segment.rows))
100 last.rows = append(last.rows, segment.rows[start:end]...)107 last.rows = append(last.rows, segment.rows[start:end]...)
101 last.bounds = append(last.bounds, segment.bounds[start:end]...)108 last.bounds = append(last.bounds, segment.bounds[start:end]...)
109+ last.rowIDs = append(last.rowIDs, segment.rowIDs[start:end]...)
102 start = end110 start = end
103 continue111 continue
104 }112 }
@@ -108,6 +116,7 @@ func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) {
108 view.chunks = append(view.chunks, &HistoryChunk{116 view.chunks = append(view.chunks, &HistoryChunk{
109 rows: append([][]renderer.Cell(nil), segment.rows[start:end]...),117 rows: append([][]renderer.Cell(nil), segment.rows[start:end]...),
110 bounds: append([]LineBound(nil), segment.bounds[start:end]...),118 bounds: append([]LineBound(nil), segment.bounds[start:end]...),
119+ rowIDs: append([]RowID(nil), segment.rowIDs[start:end]...),
111 })120 })
112 start = end121 start = end
113 }122 }
M pkg/vt/recovery_transcript_test.go +7 -3
@@ -188,7 +188,7 @@ func TestRecoveryTranscriptSnapshotMarshalsCanonicalEmptyHistory(t *testing.T) {
188188
189189 got, err := s.RecoveryTranscriptSnapshot().Marshal()
190190 require.NoError(t, err)
191- want, err := MarshalHistory(HistoryView{})
191+ want, err := MarshalHistory(HistoryView{nextRowID: 3})
192192 require.NoError(t, err)
193193
194194 require.Equal(t, want, got)
@@ -396,9 +396,13 @@ func recoveryTranscriptBlob(t testing.TB, texts []string, bounds []LineBound) []
396396 bounds[i].End = len(row)
397397 }
398398 }
399- view := HistoryView{rows: len(rows), cells: cells}
399+ rowIDs := make([]RowID, len(rows))
400+ for i := range rowIDs {
401+ rowIDs[i] = RowID(100 + i)
402+ }
403+ view := HistoryView{rows: len(rows), cells: cells, nextRowID: RowID(100 + len(rows))}
400404 if len(rows) > 0 {
401- view.chunks = []*HistoryChunk{{rows: rows, bounds: bounds}}
405+ view.chunks = []*HistoryChunk{{rows: rows, bounds: bounds, rowIDs: rowIDs}}
402406 }
403407 blob, err := MarshalHistory(view)
404408 require.NoError(t, err)
@@ -188,7 +188,7 @@ func TestRecoveryTranscriptSnapshotMarshalsCanonicalEmptyHistory(t *testing.T) {
188 188
189 got, err := s.RecoveryTranscriptSnapshot().Marshal()189 got, err := s.RecoveryTranscriptSnapshot().Marshal()
190 require.NoError(t, err)190 require.NoError(t, err)
191- want, err := MarshalHistory(HistoryView{})191+ want, err := MarshalHistory(HistoryView{nextRowID: 3})
192 require.NoError(t, err)192 require.NoError(t, err)
193 193
194 require.Equal(t, want, got)194 require.Equal(t, want, got)
@@ -396,9 +396,13 @@ func recoveryTranscriptBlob(t testing.TB, texts []string, bounds []LineBound) []
396 bounds[i].End = len(row)396 bounds[i].End = len(row)
397 }397 }
398 }398 }
399- view := HistoryView{rows: len(rows), cells: cells}399+ rowIDs := make([]RowID, len(rows))
400+ for i := range rowIDs {
401+ rowIDs[i] = RowID(100 + i)
402+ }
403+ view := HistoryView{rows: len(rows), cells: cells, nextRowID: RowID(100 + len(rows))}
400 if len(rows) > 0 {404 if len(rows) > 0 {
401- view.chunks = []*HistoryChunk{{rows: rows, bounds: bounds}}405+ view.chunks = []*HistoryChunk{{rows: rows, bounds: bounds, rowIDs: rowIDs}}
402 }406 }
403 blob, err := MarshalHistory(view)407 blob, err := MarshalHistory(view)
404 require.NoError(t, err)408 require.NoError(t, err)
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)-2 {
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)-2 {
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_persistence_test.go +222 -0
@@ -0,0 +1,222 @@
1+package vt
2+
3+import (
4+ "encoding/binary"
5+ "testing"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestHistoryCodecRejectsMalformedRowIDsAndCounters(t *testing.T) {
12+ history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 4})
13+ require.NoError(t, history.Append(historyRow("a"), LineBound{End: 1}))
14+ require.NoError(t, history.Append(historyRow("b"), LineBound{End: 1}))
15+ encoded, err := MarshalHistory(history.SealAndView())
16+ require.NoError(t, err)
17+
18+ firstIDOffset := 17 + 4
19+ secondIDOffset := firstIDOffset + 8 + 4 + historyCellBytes + historyBoundBytes
20+ for _, test := range []struct {
21+ name string
22+ mutate func([]byte)
23+ }{
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]) }},
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)) }},
28+ } {
29+ t.Run(test.name, func(t *testing.T) {
30+ data := append([]byte(nil), encoded...)
31+ test.mutate(data)
32+ _, unmarshalErr := UnmarshalHistory(data)
33+ require.Error(t, unmarshalErr)
34+ _, preflightErr := PreflightHistoryBlob(data)
35+ require.Error(t, preflightErr)
36+ })
37+ }
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+
46+func TestHistoryAppendWithIDRejectsMalformedIDs(t *testing.T) {
47+ const maxPersistedRowID = ^RowID(0) - 2
48+ for _, test := range []struct {
49+ name string
50+ id RowID
51+ existing bool
52+ }{
53+ {name: "zero", id: 0},
54+ {name: "duplicate", id: 7, existing: true},
55+ {name: "first unpersistable", id: maxPersistedRowID + 1},
56+ {name: "maximum", id: ^RowID(0)},
57+ } {
58+ t.Run(test.name, func(t *testing.T) {
59+ history := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 2})
60+ if test.existing {
61+ require.NoError(t, history.AppendWithID(historyRow("existing"), LineBound{End: 8}, test.id))
62+ }
63+ before := history.View()
64+ require.Error(t, history.AppendWithID(historyRow("row"), LineBound{End: 3}, test.id))
65+ require.Equal(t, before.Len(), history.Len())
66+ require.Equal(t, before.NextRowID(), history.NextRowID())
67+ })
68+ }
69+}
70+
71+func TestHistoryAppendWithIDValidatesOutOfOrderDuplicates(t *testing.T) {
72+ history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 1})
73+ require.NoError(t, history.AppendWithID(historyRow("high"), LineBound{End: 4}, RowID(100)))
74+ require.NoError(t, history.AppendWithID(historyRow("low"), LineBound{End: 3}, RowID(7)))
75+ require.Equal(t, RowID(101), history.NextRowID())
76+
77+ before := history.View()
78+ require.Error(t, history.AppendWithID(historyRow("duplicate"), LineBound{End: 9}, RowID(7)))
79+ require.Equal(t, before.Len(), history.Len())
80+ require.Equal(t, before.NextRowID(), history.NextRowID())
81+}
82+
83+func TestHistoryAutomaticAllocationStaysAboveAcceptedIDsAfterEviction(t *testing.T) {
84+ history := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1})
85+ require.NoError(t, history.AppendWithID(historyRow("high"), LineBound{End: 4}, RowID(100)))
86+ require.NoError(t, history.AppendWithID(historyRow("low"), LineBound{End: 3}, RowID(7)))
87+ require.NoError(t, history.AppendWithID(historyRow("next-low"), LineBound{End: 8}, RowID(8)))
88+ require.NoError(t, history.AppendWithID(historyRow("evict"), LineBound{End: 5}, RowID(9)))
89+ require.Equal(t, RowID(101), history.NextRowID())
90+
91+ require.NoError(t, history.Append(historyRow("automatic"), LineBound{End: 9}))
92+ view := history.View()
93+ require.Equal(t, RowID(101), view.RowID(view.Len()-1))
94+ require.Equal(t, RowID(102), history.NextRowID())
95+ for i := range view.Len() {
96+ require.Less(t, view.RowID(i), history.NextRowID())
97+ }
98+}
99+
100+func TestHistoryAutomaticAllocationBoundary(t *testing.T) {
101+ lastPersistableID := ^RowID(0) - 2
102+ history := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1})
103+ history.nextRowID = lastPersistableID
104+
105+ require.NoError(t, history.Append(historyRow("last"), LineBound{End: 4}))
106+ require.Equal(t, lastPersistableID+1, history.NextRowID())
107+ before := history.View()
108+ require.Error(t, history.Append(historyRow("exhausted"), LineBound{End: 9}))
109+ require.Equal(t, before.Len(), history.Len())
110+ require.Equal(t, before.NextRowID(), history.NextRowID())
111+}
112+
113+func TestScreenRowIDBoundaryMatchesHistoryPersistence(t *testing.T) {
114+ const maxPersistedRowID = ^RowID(0) - 2
115+ for _, test := range []struct {
116+ name string
117+ current RowID
118+ want RowID
119+ persist bool
120+ }{
121+ {name: "last persistable ID", current: maxPersistedRowID - 1, want: maxPersistedRowID, persist: true},
122+ {name: "first non-persistable ID", current: maxPersistedRowID, want: maxPersistedRowID + 1},
123+ } {
124+ t.Run(test.name, func(t *testing.T) {
125+ screen := NewScreen(1, 1)
126+ screen.nextRowID = test.current
127+ allocate := func() {
128+ require.Equal(t, test.want, screen.nextRowIDValue())
129+ }
130+ if !test.persist {
131+ require.Panics(t, allocate)
132+ return
133+ }
134+
135+ allocate()
136+ row := historyRow("x")
137+ screen.buffer.rowIDs[0] = test.want
138+ screen.buffer.frame.Row(0)[0] = row[0]
139+ screen.buffer.boundaries[0] = LineBound{End: 1}
140+ transcript, err := screen.RecoveryTranscriptSnapshot().Marshal()
141+ require.NoError(t, err)
142+ view, err := UnmarshalHistory(transcript)
143+ require.NoError(t, err)
144+ require.Equal(t, test.want, view.RowID(0))
145+ require.Equal(t, test.want+1, view.NextRowID())
146+
147+ history := NewHistory(HistoryConfig{MaxRows: 1, ChunkRows: 1})
148+ require.NoError(t, history.AppendWithID(row, LineBound{End: 1}, screen.RowID(0)))
149+ _, err = MarshalHistory(history.SealAndView())
150+ require.NoError(t, err)
151+ })
152+ }
153+}
154+
155+func TestHistoryCodecRejectsMissingRowIDsOnMarshal(t *testing.T) {
156+ _, err := MarshalHistory(HistoryView{
157+ chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("row")}, bounds: []LineBound{{End: 3}}}},
158+ rows: 1,
159+ })
160+ require.Error(t, err)
161+}
162+
163+func TestRestoredScreenAllocatesAbovePersistedRowIDs(t *testing.T) {
164+ history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 1})
165+ require.NoError(t, history.AppendWithID(historyRow("old"), LineBound{End: 3}, RowID(40)))
166+ sealed, tail, err := MarshalSealedHistory(history.SealAndView())
167+ require.NoError(t, err)
168+ transcript, err := MarshalHistory(HistoryView{
169+ chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("live")}, bounds: []LineBound{{End: 4}}, rowIDs: []RowID{50}}},
170+ rows: 1,
171+ nextRowID: 51,
172+ })
173+ require.NoError(t, err)
174+
175+ screen, err := NewScreenWithRecoveryTranscript(3, 1, HistoryConfig{MaxRows: 8, ChunkRows: 2}, sealed, tail, transcript)
176+ require.NoError(t, err)
177+ require.Equal(t, RowID(40), screen.History().View().RowID(0))
178+ require.Equal(t, RowID(50), screen.History().View().RowID(1))
179+ require.Equal(t, RowID(51), screen.History().NextRowID())
180+ require.Greater(t, screen.RowID(0), RowID(50))
181+
182+ before := screen.RowID(0)
183+ screen.Write([]byte("\x1b[2J"))
184+ require.Greater(t, screen.RowID(0), before)
185+}
186+
187+func TestHistoryRestoreRejectsDuplicateIDsAcrossEvictedHistoryAndTranscript(t *testing.T) {
188+ sealedView := HistoryView{
189+ chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("old")}, bounds: []LineBound{{End: 3}}, rowIDs: []RowID{7}}},
190+ rows: 1,
191+ nextRowID: 8,
192+ }
193+ sealed, err := MarshalHistoryChunk(sealedView.Chunk(0))
194+ require.NoError(t, err)
195+ tail, err := MarshalEmptyHistoryTail()
196+ require.NoError(t, err)
197+ transcript, err := MarshalHistory(HistoryView{
198+ chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("live")}, bounds: []LineBound{{End: 4}}, rowIDs: []RowID{7}}},
199+ rows: 1,
200+ nextRowID: 8,
201+ })
202+ require.NoError(t, err)
203+
204+ _, err = NewScreenWithRecoveryTranscript(4, 1, HistoryConfig{MaxRows: 1, ChunkRows: 1}, [][]byte{sealed}, tail, transcript)
205+ require.Error(t, err)
206+}
207+
208+func TestHistoryRestoreRejectsDuplicateIDsAcrossBlobs(t *testing.T) {
209+ first := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1})
210+ second := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1})
211+ require.NoError(t, first.AppendWithID(historyRow("one"), LineBound{End: 3}, RowID(7)))
212+ require.NoError(t, second.AppendWithID(historyRow("two"), LineBound{End: 3}, RowID(7)))
213+ firstBlob, err := MarshalHistoryChunk(first.SealAndView().Chunk(0))
214+ require.NoError(t, err)
215+ secondBlob, err := MarshalHistoryChunk(second.SealAndView().Chunk(0))
216+ require.NoError(t, err)
217+ tail, err := MarshalEmptyHistoryTail()
218+ require.NoError(t, err)
219+
220+ _, err = HistoryFromBlobs(HistoryConfig{MaxRows: 4, ChunkRows: 1}, [][]byte{firstBlob, secondBlob}, tail)
221+ require.Error(t, err)
222+}
@@ -0,0 +1,222 @@
1+package vt
2+
3+import (
4+ "encoding/binary"
5+ "testing"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestHistoryCodecRejectsMalformedRowIDsAndCounters(t *testing.T) {
12+ history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 4})
13+ require.NoError(t, history.Append(historyRow("a"), LineBound{End: 1}))
14+ require.NoError(t, history.Append(historyRow("b"), LineBound{End: 1}))
15+ encoded, err := MarshalHistory(history.SealAndView())
16+ require.NoError(t, err)
17+
18+ firstIDOffset := 17 + 4
19+ secondIDOffset := firstIDOffset + 8 + 4 + historyCellBytes + historyBoundBytes
20+ for _, test := range []struct {
21+ name string
22+ mutate func([]byte)
23+ }{
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]) }},
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)) }},
28+ } {
29+ t.Run(test.name, func(t *testing.T) {
30+ data := append([]byte(nil), encoded...)
31+ test.mutate(data)
32+ _, unmarshalErr := UnmarshalHistory(data)
33+ require.Error(t, unmarshalErr)
34+ _, preflightErr := PreflightHistoryBlob(data)
35+ require.Error(t, preflightErr)
36+ })
37+ }
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+
46+func TestHistoryAppendWithIDRejectsMalformedIDs(t *testing.T) {
47+ const maxPersistedRowID = ^RowID(0) - 2
48+ for _, test := range []struct {
49+ name string
50+ id RowID
51+ existing bool
52+ }{
53+ {name: "zero", id: 0},
54+ {name: "duplicate", id: 7, existing: true},
55+ {name: "first unpersistable", id: maxPersistedRowID + 1},
56+ {name: "maximum", id: ^RowID(0)},
57+ } {
58+ t.Run(test.name, func(t *testing.T) {
59+ history := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 2})
60+ if test.existing {
61+ require.NoError(t, history.AppendWithID(historyRow("existing"), LineBound{End: 8}, test.id))
62+ }
63+ before := history.View()
64+ require.Error(t, history.AppendWithID(historyRow("row"), LineBound{End: 3}, test.id))
65+ require.Equal(t, before.Len(), history.Len())
66+ require.Equal(t, before.NextRowID(), history.NextRowID())
67+ })
68+ }
69+}
70+
71+func TestHistoryAppendWithIDValidatesOutOfOrderDuplicates(t *testing.T) {
72+ history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 1})
73+ require.NoError(t, history.AppendWithID(historyRow("high"), LineBound{End: 4}, RowID(100)))
74+ require.NoError(t, history.AppendWithID(historyRow("low"), LineBound{End: 3}, RowID(7)))
75+ require.Equal(t, RowID(101), history.NextRowID())
76+
77+ before := history.View()
78+ require.Error(t, history.AppendWithID(historyRow("duplicate"), LineBound{End: 9}, RowID(7)))
79+ require.Equal(t, before.Len(), history.Len())
80+ require.Equal(t, before.NextRowID(), history.NextRowID())
81+}
82+
83+func TestHistoryAutomaticAllocationStaysAboveAcceptedIDsAfterEviction(t *testing.T) {
84+ history := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1})
85+ require.NoError(t, history.AppendWithID(historyRow("high"), LineBound{End: 4}, RowID(100)))
86+ require.NoError(t, history.AppendWithID(historyRow("low"), LineBound{End: 3}, RowID(7)))
87+ require.NoError(t, history.AppendWithID(historyRow("next-low"), LineBound{End: 8}, RowID(8)))
88+ require.NoError(t, history.AppendWithID(historyRow("evict"), LineBound{End: 5}, RowID(9)))
89+ require.Equal(t, RowID(101), history.NextRowID())
90+
91+ require.NoError(t, history.Append(historyRow("automatic"), LineBound{End: 9}))
92+ view := history.View()
93+ require.Equal(t, RowID(101), view.RowID(view.Len()-1))
94+ require.Equal(t, RowID(102), history.NextRowID())
95+ for i := range view.Len() {
96+ require.Less(t, view.RowID(i), history.NextRowID())
97+ }
98+}
99+
100+func TestHistoryAutomaticAllocationBoundary(t *testing.T) {
101+ lastPersistableID := ^RowID(0) - 2
102+ history := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1})
103+ history.nextRowID = lastPersistableID
104+
105+ require.NoError(t, history.Append(historyRow("last"), LineBound{End: 4}))
106+ require.Equal(t, lastPersistableID+1, history.NextRowID())
107+ before := history.View()
108+ require.Error(t, history.Append(historyRow("exhausted"), LineBound{End: 9}))
109+ require.Equal(t, before.Len(), history.Len())
110+ require.Equal(t, before.NextRowID(), history.NextRowID())
111+}
112+
113+func TestScreenRowIDBoundaryMatchesHistoryPersistence(t *testing.T) {
114+ const maxPersistedRowID = ^RowID(0) - 2
115+ for _, test := range []struct {
116+ name string
117+ current RowID
118+ want RowID
119+ persist bool
120+ }{
121+ {name: "last persistable ID", current: maxPersistedRowID - 1, want: maxPersistedRowID, persist: true},
122+ {name: "first non-persistable ID", current: maxPersistedRowID, want: maxPersistedRowID + 1},
123+ } {
124+ t.Run(test.name, func(t *testing.T) {
125+ screen := NewScreen(1, 1)
126+ screen.nextRowID = test.current
127+ allocate := func() {
128+ require.Equal(t, test.want, screen.nextRowIDValue())
129+ }
130+ if !test.persist {
131+ require.Panics(t, allocate)
132+ return
133+ }
134+
135+ allocate()
136+ row := historyRow("x")
137+ screen.buffer.rowIDs[0] = test.want
138+ screen.buffer.frame.Row(0)[0] = row[0]
139+ screen.buffer.boundaries[0] = LineBound{End: 1}
140+ transcript, err := screen.RecoveryTranscriptSnapshot().Marshal()
141+ require.NoError(t, err)
142+ view, err := UnmarshalHistory(transcript)
143+ require.NoError(t, err)
144+ require.Equal(t, test.want, view.RowID(0))
145+ require.Equal(t, test.want+1, view.NextRowID())
146+
147+ history := NewHistory(HistoryConfig{MaxRows: 1, ChunkRows: 1})
148+ require.NoError(t, history.AppendWithID(row, LineBound{End: 1}, screen.RowID(0)))
149+ _, err = MarshalHistory(history.SealAndView())
150+ require.NoError(t, err)
151+ })
152+ }
153+}
154+
155+func TestHistoryCodecRejectsMissingRowIDsOnMarshal(t *testing.T) {
156+ _, err := MarshalHistory(HistoryView{
157+ chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("row")}, bounds: []LineBound{{End: 3}}}},
158+ rows: 1,
159+ })
160+ require.Error(t, err)
161+}
162+
163+func TestRestoredScreenAllocatesAbovePersistedRowIDs(t *testing.T) {
164+ history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 1})
165+ require.NoError(t, history.AppendWithID(historyRow("old"), LineBound{End: 3}, RowID(40)))
166+ sealed, tail, err := MarshalSealedHistory(history.SealAndView())
167+ require.NoError(t, err)
168+ transcript, err := MarshalHistory(HistoryView{
169+ chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("live")}, bounds: []LineBound{{End: 4}}, rowIDs: []RowID{50}}},
170+ rows: 1,
171+ nextRowID: 51,
172+ })
173+ require.NoError(t, err)
174+
175+ screen, err := NewScreenWithRecoveryTranscript(3, 1, HistoryConfig{MaxRows: 8, ChunkRows: 2}, sealed, tail, transcript)
176+ require.NoError(t, err)
177+ require.Equal(t, RowID(40), screen.History().View().RowID(0))
178+ require.Equal(t, RowID(50), screen.History().View().RowID(1))
179+ require.Equal(t, RowID(51), screen.History().NextRowID())
180+ require.Greater(t, screen.RowID(0), RowID(50))
181+
182+ before := screen.RowID(0)
183+ screen.Write([]byte("\x1b[2J"))
184+ require.Greater(t, screen.RowID(0), before)
185+}
186+
187+func TestHistoryRestoreRejectsDuplicateIDsAcrossEvictedHistoryAndTranscript(t *testing.T) {
188+ sealedView := HistoryView{
189+ chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("old")}, bounds: []LineBound{{End: 3}}, rowIDs: []RowID{7}}},
190+ rows: 1,
191+ nextRowID: 8,
192+ }
193+ sealed, err := MarshalHistoryChunk(sealedView.Chunk(0))
194+ require.NoError(t, err)
195+ tail, err := MarshalEmptyHistoryTail()
196+ require.NoError(t, err)
197+ transcript, err := MarshalHistory(HistoryView{
198+ chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("live")}, bounds: []LineBound{{End: 4}}, rowIDs: []RowID{7}}},
199+ rows: 1,
200+ nextRowID: 8,
201+ })
202+ require.NoError(t, err)
203+
204+ _, err = NewScreenWithRecoveryTranscript(4, 1, HistoryConfig{MaxRows: 1, ChunkRows: 1}, [][]byte{sealed}, tail, transcript)
205+ require.Error(t, err)
206+}
207+
208+func TestHistoryRestoreRejectsDuplicateIDsAcrossBlobs(t *testing.T) {
209+ first := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1})
210+ second := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1})
211+ require.NoError(t, first.AppendWithID(historyRow("one"), LineBound{End: 3}, RowID(7)))
212+ require.NoError(t, second.AppendWithID(historyRow("two"), LineBound{End: 3}, RowID(7)))
213+ firstBlob, err := MarshalHistoryChunk(first.SealAndView().Chunk(0))
214+ require.NoError(t, err)
215+ secondBlob, err := MarshalHistoryChunk(second.SealAndView().Chunk(0))
216+ require.NoError(t, err)
217+ tail, err := MarshalEmptyHistoryTail()
218+ require.NoError(t, err)
219+
220+ _, err = HistoryFromBlobs(HistoryConfig{MaxRows: 4, ChunkRows: 1}, [][]byte{firstBlob, secondBlob}, tail)
221+ require.Error(t, err)
222+}
A pkg/vt/row_id_test.go +180 -0
@@ -0,0 +1,180 @@
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 TestScreenRowIDRefreshesOnSnapExpandedFullClear(t *testing.T) {
53+ tests := []struct {
54+ name string
55+ x0, x1 int
56+ wantRefresh bool
57+ }{
58+ {name: "snap-expanded full clear", x0: 1, x1: 4, wantRefresh: true},
59+ {name: "partial clear", x0: 0, x1: 1, wantRefresh: false},
60+ }
61+
62+ for _, tt := range tests {
63+ t.Run(tt.name, func(t *testing.T) {
64+ s := NewScreen(4, 1)
65+ s.Write([]byte("界"))
66+ before := s.RowID(0)
67+
68+ s.clearRow(0, tt.x0, tt.x1)
69+
70+ if got := s.RowID(0) != before; got != tt.wantRefresh {
71+ t.Fatalf("row ID refreshed = %t, want %t", got, tt.wantRefresh)
72+ }
73+ })
74+ }
75+}
76+
77+func TestScreenRowIDsRefreshOnClearResetAndAlternateClone(t *testing.T) {
78+ s := NewScreen(4, 3)
79+ before := s.RowIDs()
80+ s.Write([]byte("AAAA\x1b[2J"))
81+ cleared := s.RowIDs()
82+ assertFreshRowIDs(t, before, cleared)
83+
84+ s.Write([]byte("\x1bc"))
85+ reset := s.RowIDs()
86+ assertFreshRowIDs(t, cleared, reset)
87+
88+ s.Write([]byte("BBBB\x1b[?1049h"))
89+ alternate := s.RowIDs()
90+ assertFreshRowIDs(t, reset, alternate)
91+ s.Write([]byte("CCCC\x1b[?1049l"))
92+ for i, id := range reset {
93+ if got := s.RowID(i); got != id {
94+ t.Fatalf("cloned primary row %d ID = %d, want %d", i, got, id)
95+ }
96+ }
97+}
98+
99+func TestResizeReflowCarriesSourceRowIDsIntoHistoryAndViewport(t *testing.T) {
100+ b := newBuffer(8, 3)
101+ copy(b.frame.Row(0), historyRow("abcdefgh"))
102+ copy(b.frame.Row(1), historyRow("ijkl"))
103+ copy(b.frame.Row(2), historyRow("mnop"))
104+ b.boundaries[0] = LineBound{End: 8, Soft: true}
105+ b.boundaries[1] = LineBound{End: 4}
106+ b.boundaries[2] = LineBound{End: 4}
107+ b.rowIDs = []RowID{11, 12, 13}
108+
109+ active := &bufferCursor{row: 2, col: 4}
110+ evicted, bounds, ids := b.resize(4, 2, active, nil)
111+
112+ if len(evicted) != 2 || bounds[0].End != 4 || ids[0] != 11 || ids[1] != 0 {
113+ 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)
114+ }
115+ if got, want := b.rowIDs, []RowID{0, 13}; got[1] != want[1] || len(got) != len(want) {
116+ t.Fatalf("reflow viewport IDs = %v, want generated continuation then source ID %d", got, want[1])
117+ }
118+}
119+
120+func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) {
121+ h := NewHistory(HistoryConfig{MaxRows: 3, ChunkRows: 2})
122+ for i, text := range []string{"one", "two"} {
123+ if err := h.AppendWithID(historyRow(text), LineBound{End: len(text)}, RowID(i+1)); err != nil {
124+ t.Fatal(err)
125+ }
126+ }
127+ before := h.SealAndView()
128+ if got, want := before.RowID(0), RowID(1); got != want {
129+ t.Fatalf("sealed row ID = %d, want %d", got, want)
130+ }
131+
132+ if err := h.AppendWithID(historyRow("three"), LineBound{End: 5}, RowID(3)); err != nil {
133+ t.Fatal(err)
134+ }
135+ if got := before.FindRowID(2); got != 1 {
136+ t.Fatalf("retained view FindRowID(2) = %d, want 1", got)
137+ }
138+ after := h.SealAndView()
139+ if got := after.FindRowID(3); got != 2 {
140+ t.Fatalf("new view FindRowID(3) = %d, want 2", got)
141+ }
142+
143+ if err := h.AppendWithID(historyRow("four"), LineBound{End: 4}, RowID(4)); err != nil {
144+ t.Fatal(err)
145+ }
146+ if got := before.FindRowID(1); got != 0 {
147+ t.Fatalf("eviction mutated retained view FindRowID(1) = %d, want 0", got)
148+ }
149+ if got := h.View().FindRowID(1); got != -1 {
150+ t.Fatalf("evicted current FindRowID(1) = %d, want -1", got)
151+ }
152+}
153+
154+func assertUniqueNonzeroRowIDs(t *testing.T, ids []RowID) {
155+ t.Helper()
156+ seen := make(map[RowID]struct{}, len(ids))
157+ for _, id := range ids {
158+ if id == 0 {
159+ t.Fatal("row ID is zero")
160+ }
161+ if _, ok := seen[id]; ok {
162+ t.Fatalf("duplicate row ID %d in %v", id, ids)
163+ }
164+ seen[id] = struct{}{}
165+ }
166+}
167+
168+func assertFreshRowIDs(t *testing.T, before, after []RowID) {
169+ t.Helper()
170+ assertUniqueNonzeroRowIDs(t, after)
171+ seen := make(map[RowID]struct{}, len(before))
172+ for _, id := range before {
173+ seen[id] = struct{}{}
174+ }
175+ for _, id := range after {
176+ if _, ok := seen[id]; ok {
177+ t.Fatalf("row ID %d was reused: before=%v after=%v", id, before, after)
178+ }
179+ }
180+}
@@ -0,0 +1,180 @@
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 TestScreenRowIDRefreshesOnSnapExpandedFullClear(t *testing.T) {
53+ tests := []struct {
54+ name string
55+ x0, x1 int
56+ wantRefresh bool
57+ }{
58+ {name: "snap-expanded full clear", x0: 1, x1: 4, wantRefresh: true},
59+ {name: "partial clear", x0: 0, x1: 1, wantRefresh: false},
60+ }
61+
62+ for _, tt := range tests {
63+ t.Run(tt.name, func(t *testing.T) {
64+ s := NewScreen(4, 1)
65+ s.Write([]byte("界"))
66+ before := s.RowID(0)
67+
68+ s.clearRow(0, tt.x0, tt.x1)
69+
70+ if got := s.RowID(0) != before; got != tt.wantRefresh {
71+ t.Fatalf("row ID refreshed = %t, want %t", got, tt.wantRefresh)
72+ }
73+ })
74+ }
75+}
76+
77+func TestScreenRowIDsRefreshOnClearResetAndAlternateClone(t *testing.T) {
78+ s := NewScreen(4, 3)
79+ before := s.RowIDs()
80+ s.Write([]byte("AAAA\x1b[2J"))
81+ cleared := s.RowIDs()
82+ assertFreshRowIDs(t, before, cleared)
83+
84+ s.Write([]byte("\x1bc"))
85+ reset := s.RowIDs()
86+ assertFreshRowIDs(t, cleared, reset)
87+
88+ s.Write([]byte("BBBB\x1b[?1049h"))
89+ alternate := s.RowIDs()
90+ assertFreshRowIDs(t, reset, alternate)
91+ s.Write([]byte("CCCC\x1b[?1049l"))
92+ for i, id := range reset {
93+ if got := s.RowID(i); got != id {
94+ t.Fatalf("cloned primary row %d ID = %d, want %d", i, got, id)
95+ }
96+ }
97+}
98+
99+func TestResizeReflowCarriesSourceRowIDsIntoHistoryAndViewport(t *testing.T) {
100+ b := newBuffer(8, 3)
101+ copy(b.frame.Row(0), historyRow("abcdefgh"))
102+ copy(b.frame.Row(1), historyRow("ijkl"))
103+ copy(b.frame.Row(2), historyRow("mnop"))
104+ b.boundaries[0] = LineBound{End: 8, Soft: true}
105+ b.boundaries[1] = LineBound{End: 4}
106+ b.boundaries[2] = LineBound{End: 4}
107+ b.rowIDs = []RowID{11, 12, 13}
108+
109+ active := &bufferCursor{row: 2, col: 4}
110+ evicted, bounds, ids := b.resize(4, 2, active, nil)
111+
112+ if len(evicted) != 2 || bounds[0].End != 4 || ids[0] != 11 || ids[1] != 0 {
113+ 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)
114+ }
115+ if got, want := b.rowIDs, []RowID{0, 13}; got[1] != want[1] || len(got) != len(want) {
116+ t.Fatalf("reflow viewport IDs = %v, want generated continuation then source ID %d", got, want[1])
117+ }
118+}
119+
120+func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) {
121+ h := NewHistory(HistoryConfig{MaxRows: 3, ChunkRows: 2})
122+ for i, text := range []string{"one", "two"} {
123+ if err := h.AppendWithID(historyRow(text), LineBound{End: len(text)}, RowID(i+1)); err != nil {
124+ t.Fatal(err)
125+ }
126+ }
127+ before := h.SealAndView()
128+ if got, want := before.RowID(0), RowID(1); got != want {
129+ t.Fatalf("sealed row ID = %d, want %d", got, want)
130+ }
131+
132+ if err := h.AppendWithID(historyRow("three"), LineBound{End: 5}, RowID(3)); err != nil {
133+ t.Fatal(err)
134+ }
135+ if got := before.FindRowID(2); got != 1 {
136+ t.Fatalf("retained view FindRowID(2) = %d, want 1", got)
137+ }
138+ after := h.SealAndView()
139+ if got := after.FindRowID(3); got != 2 {
140+ t.Fatalf("new view FindRowID(3) = %d, want 2", got)
141+ }
142+
143+ if err := h.AppendWithID(historyRow("four"), LineBound{End: 4}, RowID(4)); err != nil {
144+ t.Fatal(err)
145+ }
146+ if got := before.FindRowID(1); got != 0 {
147+ t.Fatalf("eviction mutated retained view FindRowID(1) = %d, want 0", got)
148+ }
149+ if got := h.View().FindRowID(1); got != -1 {
150+ t.Fatalf("evicted current FindRowID(1) = %d, want -1", got)
151+ }
152+}
153+
154+func assertUniqueNonzeroRowIDs(t *testing.T, ids []RowID) {
155+ t.Helper()
156+ seen := make(map[RowID]struct{}, len(ids))
157+ for _, id := range ids {
158+ if id == 0 {
159+ t.Fatal("row ID is zero")
160+ }
161+ if _, ok := seen[id]; ok {
162+ t.Fatalf("duplicate row ID %d in %v", id, ids)
163+ }
164+ seen[id] = struct{}{}
165+ }
166+}
167+
168+func assertFreshRowIDs(t *testing.T, before, after []RowID) {
169+ t.Helper()
170+ assertUniqueNonzeroRowIDs(t, after)
171+ seen := make(map[RowID]struct{}, len(before))
172+ for _, id := range before {
173+ seen[id] = struct{}{}
174+ }
175+ for _, id := range after {
176+ if _, ok := seen[id]; ok {
177+ t.Fatalf("row ID %d was reused: before=%v after=%v", id, before, after)
178+ }
179+ }
180+}
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.AppendWithID(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.AppendWithID(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_snapshot.go +34 -7
@@ -25,11 +25,13 @@ type ModeSnapshot struct {
2525 // terminal viewport. Row returns caller-owned storage; BorrowedRow must not be
2626 // mutated and remains valid while the snapshot is retained.
2727 type ScreenSnapshot struct {
28- frame renderer.Frame
29- bounds []LineBound
30- cursor CursorSnapshot
31- modes ModeSnapshot
32- title string
28+ frame renderer.Frame
29+ bounds []LineBound
30+ rowIDs []RowID
31+ cursor CursorSnapshot
32+ modes ModeSnapshot
33+ title string
34+ nextRowID RowID
3335 }
3436
3537 // Snapshot captures the active visible viewport without mutating Screen,
@@ -41,9 +43,15 @@ func (s *Screen) Snapshot() ScreenSnapshot {
4143 }
4244 cursorStyle, cursorStyleSet := s.CursorStyle()
4345 mouseTracking, mouseSGR := s.MouseMode()
46+ nextRowID := s.nextRowID
47+ if nextRowID < ^RowID(0) {
48+ nextRowID++
49+ }
4450 return ScreenSnapshot{
45- frame: s.Frame.Clone(),
46- bounds: s.LineBounds(),
51+ frame: s.Frame.Clone(),
52+ bounds: s.LineBounds(),
53+ rowIDs: s.RowIDs(),
54+ nextRowID: nextRowID,
4755 cursor: CursorSnapshot{
4856 Row: s.CursorRow(),
4957 Col: s.CursorCol(),
@@ -84,6 +92,25 @@ func (s ScreenSnapshot) Bound(y int) LineBound {
8492 return s.bounds[y]
8593 }
8694
95+// RowIDs returns an owned copy of the visible physical-row identities.
96+func (s ScreenSnapshot) RowIDs() []RowID { return append([]RowID(nil), s.rowIDs...) }
97+
98+// RowID returns the identity of visible row y, or zero when out of range.
99+func (s ScreenSnapshot) RowID(y int) RowID {
100+ if y < 0 || y >= len(s.rowIDs) {
101+ return 0
102+ }
103+ return s.rowIDs[y]
104+}
105+
106+// NextRowID returns the next identity allocated by the captured screen.
107+func (s ScreenSnapshot) NextRowID() RowID {
108+ if s.nextRowID == 0 {
109+ return 1
110+ }
111+ return s.nextRowID
112+}
113+
87114 func (s ScreenSnapshot) Cursor() CursorSnapshot { return s.cursor }
88115 func (s ScreenSnapshot) Modes() ModeSnapshot { return s.modes }
89116 func (s ScreenSnapshot) Title() string { return s.title }
@@ -25,11 +25,13 @@ type ModeSnapshot struct {
25 // terminal viewport. Row returns caller-owned storage; BorrowedRow must not be25 // terminal viewport. Row returns caller-owned storage; BorrowedRow must not be
26 // mutated and remains valid while the snapshot is retained.26 // mutated and remains valid while the snapshot is retained.
27 type ScreenSnapshot struct {27 type ScreenSnapshot struct {
28- frame renderer.Frame28+ frame renderer.Frame
29- bounds []LineBound29+ bounds []LineBound
30- cursor CursorSnapshot30+ rowIDs []RowID
31- modes ModeSnapshot31+ cursor CursorSnapshot
32- title string32+ modes ModeSnapshot
33+ title string
34+ nextRowID RowID
33 }35 }
34 36
35 // Snapshot captures the active visible viewport without mutating Screen,37 // Snapshot captures the active visible viewport without mutating Screen,
@@ -41,9 +43,15 @@ func (s *Screen) Snapshot() ScreenSnapshot {
41 }43 }
42 cursorStyle, cursorStyleSet := s.CursorStyle()44 cursorStyle, cursorStyleSet := s.CursorStyle()
43 mouseTracking, mouseSGR := s.MouseMode()45 mouseTracking, mouseSGR := s.MouseMode()
46+ nextRowID := s.nextRowID
47+ if nextRowID < ^RowID(0) {
48+ nextRowID++
49+ }
44 return ScreenSnapshot{50 return ScreenSnapshot{
45- frame: s.Frame.Clone(),51+ frame: s.Frame.Clone(),
46- bounds: s.LineBounds(),52+ bounds: s.LineBounds(),
53+ rowIDs: s.RowIDs(),
54+ nextRowID: nextRowID,
47 cursor: CursorSnapshot{55 cursor: CursorSnapshot{
48 Row: s.CursorRow(),56 Row: s.CursorRow(),
49 Col: s.CursorCol(),57 Col: s.CursorCol(),
@@ -84,6 +92,25 @@ func (s ScreenSnapshot) Bound(y int) LineBound {
84 return s.bounds[y]92 return s.bounds[y]
85 }93 }
86 94
95+// RowIDs returns an owned copy of the visible physical-row identities.
96+func (s ScreenSnapshot) RowIDs() []RowID { return append([]RowID(nil), s.rowIDs...) }
97+
98+// RowID returns the identity of visible row y, or zero when out of range.
99+func (s ScreenSnapshot) RowID(y int) RowID {
100+ if y < 0 || y >= len(s.rowIDs) {
101+ return 0
102+ }
103+ return s.rowIDs[y]
104+}
105+
106+// NextRowID returns the next identity allocated by the captured screen.
107+func (s ScreenSnapshot) NextRowID() RowID {
108+ if s.nextRowID == 0 {
109+ return 1
110+ }
111+ return s.nextRowID
112+}
113+
87 func (s ScreenSnapshot) Cursor() CursorSnapshot { return s.cursor }114 func (s ScreenSnapshot) Cursor() CursorSnapshot { return s.cursor }
88 func (s ScreenSnapshot) Modes() ModeSnapshot { return s.modes }115 func (s ScreenSnapshot) Modes() ModeSnapshot { return s.modes }
89 func (s ScreenSnapshot) Title() string { return s.title }116 func (s ScreenSnapshot) Title() string { return s.title }
M pkg/vt/screen_write.go +4 -0
@@ -202,11 +202,15 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
202202 if x1 < s.Frame.Width && s.Frame.At(x1, y).Continuation {
203203 x1++
204204 }
205+ fullRow := x0 <= 0 && x1 >= s.Frame.Width
205206 blank := s.eraseCell()
206207 for x := x0; x < x1; x++ {
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
@@ -202,11 +202,15 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
202 if x1 < s.Frame.Width && s.Frame.At(x1, y).Continuation {202 if x1 < s.Frame.Width && s.Frame.At(x1, y).Continuation {
203 x1++203 x1++
204 }204 }
205+ fullRow := x0 <= 0 && x1 >= s.Frame.Width
205 blank := s.eraseCell()206 blank := s.eraseCell()
206 for x := x0; x < x1; x++ {207 for x := x0; x < x1; x++ {
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 +86 -21
@@ -53,7 +53,8 @@ func MarshalHistoryTail(view HistorySnapshotView) ([]byte, error) {
5353 }
5454
5555 // MarshalSealedHistory serializes a SealAndView result as oldest-first,
56-// self-contained sealed blobs plus a mandatory empty canonical tail blob.
56+// self-contained sealed blobs plus a mandatory empty tail blob. The empty tail
57+// carries NextRowID and is not the canonical default empty encoding.
5758 func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) {
5859 if view.rows != historyViewRowCount(view) {
5960 return nil, nil, fmt.Errorf("marshal sealed history: %w", errInvalidHistory)
@@ -66,7 +67,7 @@ func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) {
6667 }
6768 sealed[i] = blob
6869 }
69- tail, err := MarshalEmptyHistoryTail()
70+ tail, err := MarshalHistory(HistoryView{nextRowID: view.NextRowID()})
7071 if err != nil {
7172 return nil, nil, err
7273 }
@@ -76,31 +77,85 @@ func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) {
7677 // HistoryFromBlobs restores history directly from sealed, oldest-first blobs
7778 // and the mandatory tail blob. It never feeds decoded rows through Append.
7879 func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*History, error) {
79- h := NewHistory(config)
80- for _, blob := range sealed {
80+ sealedViews, tailView, err := decodeRestoredHistoryBlobs(sealed, tail)
81+ if err != nil {
82+ return nil, err
83+ }
84+ return restoreHistoryViews(config, sealedViews, tailView), nil
85+}
86+
87+func decodeRestoredHistoryBlobs(sealed [][]byte, tail []byte, extra ...HistoryView) ([]HistoryView, HistoryView, error) {
88+ seen := make(map[RowID]struct{})
89+ sealedViews := make([]HistoryView, len(sealed))
90+ for i, blob := range sealed {
8191 view, err := UnmarshalHistory(blob)
82- if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 {
83- return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory)
92+ if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 || !validateRestoredHistoryView(view, seen) {
93+ return nil, HistoryView{}, fmt.Errorf("restore sealed history: %w", errInvalidHistory)
8494 }
85- h.evictUntil(len(view.chunks[0].rows), view.Cells())
86- h.chunks = append(h.chunks, view.chunks[0])
87- h.rows += len(view.chunks[0].rows)
88- h.cells += view.Cells()
95+ sealedViews[i] = view
8996 }
90- view, err := UnmarshalHistory(tail)
91- if err != nil || len(view.chunks) > 1 {
92- return nil, fmt.Errorf("restore history tail: %w", errInvalidHistory)
97+
98+ tailView, err := UnmarshalHistory(tail)
99+ if err != nil || len(tailView.chunks) > 1 || !validateRestoredHistoryView(tailView, seen) {
100+ return nil, HistoryView{}, fmt.Errorf("restore history tail: %w", errInvalidHistory)
101+ }
102+ for _, view := range extra {
103+ if !validateRestoredHistoryView(view, seen) {
104+ return nil, HistoryView{}, fmt.Errorf("restore recovery transcript: %w", errInvalidHistory)
105+ }
93106 }
94- if len(view.chunks) == 1 {
95- h.evictUntil(len(view.chunks[0].rows), view.Cells())
96- h.tail = view.chunks[0].rows
97- h.tailBounds = growBounds(view.chunks[0].bounds, len(h.tail))
98- h.rows += len(h.tail)
107+ return sealedViews, tailView, nil
108+}
109+
110+func restoreHistoryViews(config HistoryConfig, sealed []HistoryView, tail HistoryView) *History {
111+ h := NewHistory(config)
112+ for _, view := range sealed {
113+ h.nextRowID = max(h.nextRowID, view.nextRowID)
114+ if h.maxRows == 0 {
115+ continue
116+ }
117+ chunk := view.chunks[0]
118+ h.chunks = append(h.chunks, chunk)
119+ h.rows += len(chunk.rows)
99120 h.cells += view.Cells()
121+ h.evict()
122+ }
123+ h.nextRowID = max(h.nextRowID, tail.nextRowID)
124+ if h.maxRows == 0 || len(tail.chunks) == 0 {
125+ return h
100126 }
127+ chunk := tail.chunks[0]
128+ h.tail = chunk.rows
129+ h.tailBounds = append([]LineBound(nil), chunk.bounds...)
130+ h.tailIDs = append([]RowID(nil), chunk.rowIDs...)
131+ h.rows += len(h.tail)
132+ h.cells += tail.Cells()
101133 h.evict()
102134 h.normalizeTail()
103- return h, nil
135+ return h
136+}
137+
138+func validateRestoredHistoryView(view HistoryView, seen map[RowID]struct{}) bool {
139+ if view.nextRowID == 0 || view.nextRowID == ^RowID(0) {
140+ return false
141+ }
142+ var maxID RowID
143+ for _, chunk := range view.chunks {
144+ if chunk == nil || len(chunk.rows) != len(chunk.rowIDs) || len(chunk.rows) != len(chunk.bounds) {
145+ return false
146+ }
147+ for _, id := range chunk.rowIDs {
148+ if id == 0 || id >= ^RowID(0)-1 {
149+ return false
150+ }
151+ if _, duplicate := seen[id]; duplicate {
152+ return false
153+ }
154+ seen[id] = struct{}{}
155+ maxID = max(maxID, id)
156+ }
157+ }
158+ return view.nextRowID > maxID
104159 }
105160
106161 // NewScreenWithRecoveryTranscript constructs a fresh blank screen whose
@@ -112,10 +167,11 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se
112167 return nil, fmt.Errorf("restore recovery transcript: %w", err)
113168 }
114169
115- history, err := HistoryFromBlobs(config, sealed, tail)
170+ sealedViews, tailView, err := decodeRestoredHistoryBlobs(sealed, tail, transcriptView)
116171 if err != nil {
117172 return nil, err
118173 }
174+ history := restoreHistoryViews(config, sealedViews, tailView)
119175 remainingRows := transcriptView.rows
120176 for _, chunk := range transcriptView.chunks {
121177 for i, row := range chunk.rows {
@@ -124,7 +180,7 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se
124180 if remainingRows == 0 {
125181 bound.Soft = false
126182 }
127- if err := history.Append(row, bound); err != nil {
183+ if err := history.AppendWithID(row, bound, chunk.rowIDs[i]); err != nil {
128184 return nil, fmt.Errorf("restore recovery transcript: %w", err)
129185 }
130186 }
@@ -132,5 +188,14 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se
132188
133189 screen := NewScreenWithHistory(width, height, config)
134190 screen.history = history
191+ counter := max(history.nextRowID, transcriptView.nextRowID, RowID(1))
192+ if counter == 0 || counter >= ^RowID(0) || uint64(height) > uint64(^RowID(0)-1-counter) {
193+ return nil, fmt.Errorf("restore recovery transcript: %w", errInvalidHistory)
194+ }
195+ screen.nextRowID = counter - 1
196+ for i := range screen.buffer.rowIDs {
197+ screen.buffer.rowIDs[i] = screen.nextRowIDValue()
198+ }
199+ screen.Frame = screen.buffer.frame
135200 return screen, nil
136201 }
@@ -53,7 +53,8 @@ func MarshalHistoryTail(view HistorySnapshotView) ([]byte, error) {
53 }53 }
54 54
55 // MarshalSealedHistory serializes a SealAndView result as oldest-first,55 // MarshalSealedHistory serializes a SealAndView result as oldest-first,
56-// self-contained sealed blobs plus a mandatory empty canonical tail blob.56+// self-contained sealed blobs plus a mandatory empty tail blob. The empty tail
57+// carries NextRowID and is not the canonical default empty encoding.
57 func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) {58 func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) {
58 if view.rows != historyViewRowCount(view) {59 if view.rows != historyViewRowCount(view) {
59 return nil, nil, fmt.Errorf("marshal sealed history: %w", errInvalidHistory)60 return nil, nil, fmt.Errorf("marshal sealed history: %w", errInvalidHistory)
@@ -66,7 +67,7 @@ func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) {
66 }67 }
67 sealed[i] = blob68 sealed[i] = blob
68 }69 }
69- tail, err := MarshalEmptyHistoryTail()70+ tail, err := MarshalHistory(HistoryView{nextRowID: view.NextRowID()})
70 if err != nil {71 if err != nil {
71 return nil, nil, err72 return nil, nil, err
72 }73 }
@@ -76,31 +77,85 @@ func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) {
76 // HistoryFromBlobs restores history directly from sealed, oldest-first blobs77 // HistoryFromBlobs restores history directly from sealed, oldest-first blobs
77 // and the mandatory tail blob. It never feeds decoded rows through Append.78 // and the mandatory tail blob. It never feeds decoded rows through Append.
78 func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*History, error) {79 func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*History, error) {
79- h := NewHistory(config)80+ sealedViews, tailView, err := decodeRestoredHistoryBlobs(sealed, tail)
80- for _, blob := range sealed {81+ if err != nil {
82+ return nil, err
83+ }
84+ return restoreHistoryViews(config, sealedViews, tailView), nil
85+}
86+
87+func decodeRestoredHistoryBlobs(sealed [][]byte, tail []byte, extra ...HistoryView) ([]HistoryView, HistoryView, error) {
88+ seen := make(map[RowID]struct{})
89+ sealedViews := make([]HistoryView, len(sealed))
90+ for i, blob := range sealed {
81 view, err := UnmarshalHistory(blob)91 view, err := UnmarshalHistory(blob)
82- if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 {92+ if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 || !validateRestoredHistoryView(view, seen) {
83- return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory)93+ return nil, HistoryView{}, fmt.Errorf("restore sealed history: %w", errInvalidHistory)
84 }94 }
85- h.evictUntil(len(view.chunks[0].rows), view.Cells())95+ sealedViews[i] = view
86- h.chunks = append(h.chunks, view.chunks[0])
87- h.rows += len(view.chunks[0].rows)
88- h.cells += view.Cells()
89 }96 }
90- view, err := UnmarshalHistory(tail)97+
91- if err != nil || len(view.chunks) > 1 {98+ tailView, err := UnmarshalHistory(tail)
92- return nil, fmt.Errorf("restore history tail: %w", errInvalidHistory)99+ if err != nil || len(tailView.chunks) > 1 || !validateRestoredHistoryView(tailView, seen) {
100+ return nil, HistoryView{}, fmt.Errorf("restore history tail: %w", errInvalidHistory)
101+ }
102+ for _, view := range extra {
103+ if !validateRestoredHistoryView(view, seen) {
104+ return nil, HistoryView{}, fmt.Errorf("restore recovery transcript: %w", errInvalidHistory)
105+ }
93 }106 }
94- if len(view.chunks) == 1 {107+ return sealedViews, tailView, nil
95- h.evictUntil(len(view.chunks[0].rows), view.Cells())108+}
96- h.tail = view.chunks[0].rows109+
97- h.tailBounds = growBounds(view.chunks[0].bounds, len(h.tail))110+func restoreHistoryViews(config HistoryConfig, sealed []HistoryView, tail HistoryView) *History {
98- h.rows += len(h.tail)111+ h := NewHistory(config)
112+ for _, view := range sealed {
113+ h.nextRowID = max(h.nextRowID, view.nextRowID)
114+ if h.maxRows == 0 {
115+ continue
116+ }
117+ chunk := view.chunks[0]
118+ h.chunks = append(h.chunks, chunk)
119+ h.rows += len(chunk.rows)
99 h.cells += view.Cells()120 h.cells += view.Cells()
121+ h.evict()
122+ }
123+ h.nextRowID = max(h.nextRowID, tail.nextRowID)
124+ if h.maxRows == 0 || len(tail.chunks) == 0 {
125+ return h
100 }126 }
127+ chunk := tail.chunks[0]
128+ h.tail = chunk.rows
129+ h.tailBounds = append([]LineBound(nil), chunk.bounds...)
130+ h.tailIDs = append([]RowID(nil), chunk.rowIDs...)
131+ h.rows += len(h.tail)
132+ h.cells += tail.Cells()
101 h.evict()133 h.evict()
102 h.normalizeTail()134 h.normalizeTail()
103- return h, nil135+ return h
136+}
137+
138+func validateRestoredHistoryView(view HistoryView, seen map[RowID]struct{}) bool {
139+ if view.nextRowID == 0 || view.nextRowID == ^RowID(0) {
140+ return false
141+ }
142+ var maxID RowID
143+ for _, chunk := range view.chunks {
144+ if chunk == nil || len(chunk.rows) != len(chunk.rowIDs) || len(chunk.rows) != len(chunk.bounds) {
145+ return false
146+ }
147+ for _, id := range chunk.rowIDs {
148+ if id == 0 || id >= ^RowID(0)-1 {
149+ return false
150+ }
151+ if _, duplicate := seen[id]; duplicate {
152+ return false
153+ }
154+ seen[id] = struct{}{}
155+ maxID = max(maxID, id)
156+ }
157+ }
158+ return view.nextRowID > maxID
104 }159 }
105 160
106 // NewScreenWithRecoveryTranscript constructs a fresh blank screen whose161 // NewScreenWithRecoveryTranscript constructs a fresh blank screen whose
@@ -112,10 +167,11 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se
112 return nil, fmt.Errorf("restore recovery transcript: %w", err)167 return nil, fmt.Errorf("restore recovery transcript: %w", err)
113 }168 }
114 169
115- history, err := HistoryFromBlobs(config, sealed, tail)170+ sealedViews, tailView, err := decodeRestoredHistoryBlobs(sealed, tail, transcriptView)
116 if err != nil {171 if err != nil {
117 return nil, err172 return nil, err
118 }173 }
174+ history := restoreHistoryViews(config, sealedViews, tailView)
119 remainingRows := transcriptView.rows175 remainingRows := transcriptView.rows
120 for _, chunk := range transcriptView.chunks {176 for _, chunk := range transcriptView.chunks {
121 for i, row := range chunk.rows {177 for i, row := range chunk.rows {
@@ -124,7 +180,7 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se
124 if remainingRows == 0 {180 if remainingRows == 0 {
125 bound.Soft = false181 bound.Soft = false
126 }182 }
127- if err := history.Append(row, bound); err != nil {183+ if err := history.AppendWithID(row, bound, chunk.rowIDs[i]); err != nil {
128 return nil, fmt.Errorf("restore recovery transcript: %w", err)184 return nil, fmt.Errorf("restore recovery transcript: %w", err)
129 }185 }
130 }186 }
@@ -132,5 +188,14 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se
132 188
133 screen := NewScreenWithHistory(width, height, config)189 screen := NewScreenWithHistory(width, height, config)
134 screen.history = history190 screen.history = history
191+ counter := max(history.nextRowID, transcriptView.nextRowID, RowID(1))
192+ if counter == 0 || counter >= ^RowID(0) || uint64(height) > uint64(^RowID(0)-1-counter) {
193+ return nil, fmt.Errorf("restore recovery transcript: %w", errInvalidHistory)
194+ }
195+ screen.nextRowID = counter - 1
196+ for i := range screen.buffer.rowIDs {
197+ screen.buffer.rowIDs[i] = screen.nextRowIDValue()
198+ }
199+ screen.Frame = screen.buffer.frame
135 return screen, nil200 return screen, nil
136 }201 }
M pkg/vt/snapshot_test.go +8 -2
@@ -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)
@@ -44,8 +49,9 @@ func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) {
4449
4550 func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) {
4651 fullTail, err := MarshalHistory(HistoryView{
47- chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("AAAA"), historyRow("BBBB")}}},
48- rows: 2,
52+ chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("AAAA"), historyRow("BBBB")}, rowIDs: []RowID{1, 2}}},
53+ rows: 2,
54+ nextRowID: 3,
4955 })
5056 require.NoError(t, err)
5157
@@ -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)
@@ -44,8 +49,9 @@ func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) {
44 49
45 func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) {50 func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) {
46 fullTail, err := MarshalHistory(HistoryView{51 fullTail, err := MarshalHistory(HistoryView{
47- chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("AAAA"), historyRow("BBBB")}}},52+ chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("AAAA"), historyRow("BBBB")}, rowIDs: []RowID{1, 2}}},
48- rows: 2,53+ rows: 2,
54+ nextRowID: 3,
49 })55 })
50 require.NoError(t, err)56 require.NoError(t, err)
51 57

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 2

Tags 13

↑↓ moveenter openesc close