Merge pull request #172 from bnema/feat/stable-terminal-row-identities
feat(session): preserve stable terminal row identities
33 files · +1204 -184
M internal/usecase/copy/document.go +3 -0
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "unicode" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | "github.com/bnema/vev/pkg/vt" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 10 | 11 | // 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 | 41 | func (d *Document) Width() int { return d.snapshot.Width } | |
| 41 | 42 | func (d *Document) Height() int { return d.snapshot.Height } | |
| 42 | 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 | 47 | // Normalize returns pos as a valid glyph head. Empty physical rows have the | |
| 45 | 48 | // 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 are | 11 | // 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 the | 47 | // 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 ( | |||
| 14 | 14 | const OSC52MaxPayloadBytes = 75_000 | |
| 15 | 15 | ||
| 16 | 16 | // 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. | |
| 19 | 19 | type Snapshot struct { | |
| 20 | 20 | history vt.HistoryView | |
| 21 | 21 | screen renderer.Frame | |
| 22 | 22 | screenBounds []vt.LineBound | |
| 23 | + | screenRowIDs []vt.RowID | |
| 23 | 24 | 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 | 44 | var history vt.HistoryView | |
| 28 | 45 | if historySource != nil { | |
| 29 | 46 | history = historySource.SealAndView() | |
| @@ -32,6 +49,7 @@ func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.L | |||
| 32 | 49 | history: history, | |
| 33 | 50 | screen: screen.Clone(), | |
| 34 | 51 | screenBounds: append([]vt.LineBound(nil), bounds...), | |
| 52 | + | screenRowIDs: screenRowIDs, | |
| 35 | 53 | Width: screen.Width, | |
| 36 | 54 | Height: screen.Height, | |
| 37 | 55 | } | |
| @@ -104,6 +122,51 @@ func (s Snapshot) Bound(i int) vt.LineBound { | |||
| 104 | 122 | } | |
| 105 | 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 | 170 | func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) { | |
| 108 | 171 | i := 0 | |
| 109 | 172 | stopped := false | |
| @@ -14,16 +14,33 @@ import ( | |||||
| 14 | const OSC52MaxPayloadBytes = 75_000 | 14 | const OSC52MaxPayloadBytes = 75_000 | ||
| 15 | 15 | ||||
| 16 | // Snapshot is the immutable scrollback document: sealed history plus a cloned | 16 | // 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 | 17 | + | // 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.HistoryView | 20 | history vt.HistoryView | ||
| 21 | screen renderer.Frame | 21 | screen renderer.Frame | ||
| 22 | screenBounds []vt.LineBound | 22 | screenBounds []vt.LineBound | ||
| 23 | + | screenRowIDs []vt.RowID | |||
| 23 | Width, Height int | 24 | 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.HistoryView | 44 | 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 := 0 | 171 | i := 0 | ||
| 109 | stopped := false | 172 | stopped := false | ||
M internal/usecase/copy/mode_test.go +27 -4
| @@ -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 | 68 | func TestSnapshotBoundDispatchesLikeRow(t *testing.T) { | |
| 46 | 69 | row := func(s string) []renderer.Cell { | |
| 47 | 70 | cells := make([]renderer.Cell, 0, len(s)) | |
| @@ -58,7 +81,7 @@ func TestSnapshotBoundDispatchesLikeRow(t *testing.T) { | |||
| 58 | 81 | screen := renderer.NewFrame(4, 2) | |
| 59 | 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 | 86 | tests := []struct { | |
| 64 | 87 | name string | |
| @@ -232,7 +255,7 @@ func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) { | |||
| 232 | 255 | require.NoError(t, history.Append(unmatched, vt.LineBound{End: len(unmatched)})) | |
| 233 | 256 | } | |
| 234 | 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 | 259 | view := history.SealAndView() | |
| 237 | 260 | require.Same(t, view.Chunk(0), snapshot.history.Chunk(0)) | |
| 238 | 261 | ||
| @@ -345,7 +368,7 @@ func TestSelectedTextJoinsAcrossTheHistoryScreenBoundary(t *testing.T) { | |||
| 345 | 368 | copy(screen.Row(0), row("ef", width)) | |
| 346 | 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 | 372 | doc := NewDocument(snapshot, "") | |
| 350 | 373 | ||
| 351 | 374 | ranges := []CellRange{ | |
| @@ -414,7 +437,7 @@ func TestSelectedTextJoinsRowsWrappedByTheVT(t *testing.T) { | |||
| 414 | 437 | screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64, MaxCells: 4096}) | |
| 415 | 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 | 441 | require.Equal(t, tc.wantHistoryLen, snapshot.history.Len(), "history/screen seam moved") | |
| 419 | 442 | doc := NewDocument(snapshot, domain.DefaultWordSeparators) | |
| 420 | 443 | ||
| @@ -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 string | 87 | 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 | |||
| 68 | 68 | geometry.pane.mu.Lock() | |
| 69 | 69 | mouseMode, _ := geometry.pane.screen.MouseMode() | |
| 70 | 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 | 72 | geometry.pane.mu.Unlock() | |
| 73 | 73 | if mouseMode != 0 || altScreen { | |
| 74 | 74 | 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) { | |||
| 74 | 74 | return | |
| 75 | 75 | } | |
| 76 | 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 | 78 | p.mu.Unlock() | |
| 79 | 79 | cfg := d.currentCopyConfig() | |
| 80 | 80 | document := scopy.NewDocument(snapshot, cfg.WordSeparators) | |
| @@ -74,7 +74,7 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||||
| 74 | return | 74 | 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) { | |||
| 101 | 101 | sess.client = ac | |
| 102 | 102 | ac.setSession(sess) | |
| 103 | 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 | 105 | closing.mu.Unlock() | |
| 106 | 106 | ac.overlays.copyMu.Lock() | |
| 107 | 107 | ac.overlays.copyPane = closing | |
| @@ -146,7 +146,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) { | |||
| 146 | 146 | tb := sess.activeTab() | |
| 147 | 147 | p := tb.focusedPane() | |
| 148 | 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 | 150 | p.mu.Unlock() | |
| 151 | 151 | ||
| 152 | 152 | // Pause after candidate publication, then make the captured target stale. | |
| @@ -370,7 +370,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing. | |||
| 370 | 370 | tb := sess.activeTab() | |
| 371 | 371 | p := tb.focusedPane() | |
| 372 | 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 | 374 | p.mu.Unlock() | |
| 375 | 375 | tc.invalidate(sess, tb, p) | |
| 376 | 376 | ||
| @@ -101,7 +101,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) { | |||||
| 101 | sess.client = ac | 101 | 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 = closing | 107 | 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) { | |||
| 350 | 350 | {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) { | |
| 351 | 351 | p := sess.activeTab().focusedPane() | |
| 352 | 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 | 354 | p.mu.Unlock() | |
| 355 | 355 | require.True(t, d.publishCopyMode(sess, ac, sess.activeTab(), p, doc, nil, nil)) | |
| 356 | 356 | }}, | |
| @@ -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) { | |||
| 105 | 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 | 127 | func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) { | |
| 109 | 128 | p, release := newBlockingPTY(t) | |
| 110 | 129 | defer release() | |
| @@ -112,7 +131,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) { | |||
| 112 | 131 | tb := sess.activeTab() | |
| 113 | 132 | pane := tb.focusedPane() | |
| 114 | 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 | 135 | pane.mu.Unlock() | |
| 117 | 136 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | |
| 118 | 137 | ||
| @@ -146,7 +165,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |||
| 146 | 165 | tb := sess.activeTab() | |
| 147 | 166 | tb.focusedPane().screen = vt.NewScreen(12, 3) | |
| 148 | 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 | 169 | mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators)) | |
| 151 | 170 | ||
| 152 | 171 | bars := barState{status: sess.statusSegments(true)} | |
| @@ -434,7 +453,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||
| 434 | 453 | } | |
| 435 | 454 | p.mu.Lock() | |
| 436 | 455 | 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 | |
| 438 | 457 | }, 2*time.Second, 5*time.Millisecond) | |
| 439 | 458 | ||
| 440 | 459 | sess := firstSession(d) | |
| @@ -784,7 +803,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) { | |||
| 784 | 803 | } | |
| 785 | 804 | fp.screen.Write([]byte("live")) | |
| 786 | 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 | 807 | fp.mu.Unlock() | |
| 789 | 808 | ||
| 790 | 809 | d.enterCopyMode(sess, ac) | |
| @@ -895,7 +914,7 @@ func TestComposeCopyClientFrameStylesStatusContentAndSurround(t *testing.T) { | |||
| 895 | 914 | base := renderer.NewFrame(20, 8) | |
| 896 | 915 | pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2}) | |
| 897 | 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 | 918 | if tt.selectMode { | |
| 900 | 919 | mode.ToggleLineSelection() | |
| 901 | 920 | } | |
| @@ -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() >= 12 | 456 | + | 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 { | |||
| 458 | 458 | // wrapped out of. Tests that care about wrapped extents build the bound instead. | |
| 459 | 459 | func appendHistoryRow(tb testing.TB, history *vt.History, row []renderer.Cell) { | |
| 460 | 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 | 478 | 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) { | |||
| 200 | 200 | ac.initOverlays() | |
| 201 | 201 | history := newTestHistory(1) | |
| 202 | 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 | 205 | ac.overlays.copyMu.Lock() | |
| 206 | 206 | 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) { | |||
| 1273 | 1273 | } | |
| 1274 | 1274 | p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2}) | |
| 1275 | 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 | 1277 | mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators)) | |
| 1278 | 1278 | target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2} | |
| 1279 | 1279 | ||
| @@ -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 | |||
| 169 | 169 | // general history encoding. An empty tail is represented by its dedicated | |
| 170 | 170 | // canonical blob; a non-empty copied tail retains its rows. | |
| 171 | 171 | func marshalSnapshotTail(tail vt.HistoryView) ([]byte, error) { | |
| 172 | - | if tail.Len() == 0 { | |
| 173 | - | return vt.MarshalEmptyHistoryTail() | |
| 174 | - | } | |
| 175 | 172 | return vt.MarshalHistory(tail) | |
| 176 | 173 | } | |
| 177 | 174 | ||
| @@ -169,9 +169,6 @@ func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.Snapsho | |||||
| 169 | // general history encoding. An empty tail is represented by its dedicated | 169 | // 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 | |||
| 634 | 634 | func assertPaneDefaultColors(t *testing.T, p *pane, fg, bg renderer.RGB) { | |
| 635 | 635 | t.Helper() | |
| 636 | 636 | var got []byte | |
| 637 | + | p.mu.Lock() | |
| 637 | 638 | p.screen.OnResponse = func(b []byte) { got = append(got, b...) } | |
| 638 | 639 | p.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |
| 640 | + | p.mu.Unlock() | |
| 639 | 641 | require.True(t, strings.Contains(string(got), formatOSCColor(fg)), string(got)) | |
| 640 | 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 | 656 | want = "\x1b[?997;2n" | |
| 655 | 657 | } | |
| 656 | 658 | var got []byte | |
| 659 | + | p.mu.Lock() | |
| 657 | 660 | p.screen.OnResponse = func(b []byte) { got = append(got, b...) } | |
| 658 | 661 | p.screen.Write([]byte("\x1b[?996n")) | |
| 662 | + | p.mu.Unlock() | |
| 659 | 663 | require.Equal(t, want, string(got)) | |
| 660 | 664 | } | |
| 661 | 665 | ||
| 662 | 666 | func assertSessionColorSchemeUnknown(t *testing.T, sess *session) { | |
| 663 | 667 | t.Helper() | |
| 664 | 668 | for _, tb := range sess.tabs { | |
| 669 | + | p := tb.focusedPane() | |
| 665 | 670 | 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() | |
| 668 | 675 | require.Empty(t, got) | |
| 669 | 676 | } | |
| 670 | 677 | } | |
| @@ -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 []byte | 636 | 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 []byte | 658 | 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 []byte | 670 | 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) { | |||
| 224 | 224 | ||
| 225 | 225 | func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) { | |
| 226 | 226 | 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:]...) | |
| 234 | 235 | transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}}) | |
| 235 | 236 | emptyTranscript := transcriptBlob(t, nil) | |
| 236 | 237 | ||
| @@ -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" | |||
| 8 | 8 | type buffer struct { | |
| 9 | 9 | frame renderer.Frame | |
| 10 | 10 | boundaries []LineBound | |
| 11 | + | rowIDs []RowID | |
| 11 | 12 | } | |
| 12 | 13 | ||
| 13 | 14 | // 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 | 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 | 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 | 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 | 37 | return out | |
| 33 | 38 | } | |
| 34 | 39 | ||
| @@ -106,8 +111,10 @@ func (b *buffer) scrollUp(top, bottom, n int) { | |||
| 106 | 111 | // them. | |
| 107 | 112 | b.hard(top - 1) | |
| 108 | 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 | 115 | for y := bottom - n + 1; y <= bottom; y++ { | |
| 110 | 116 | b.boundaries[y] = LineBound{} | |
| 117 | + | b.rowIDs[y] = 0 | |
| 111 | 118 | } | |
| 112 | 119 | b.hard(bottom - n) | |
| 113 | 120 | } | |
| @@ -117,8 +124,10 @@ func (b *buffer) scrollDown(top, bottom, n int) { | |||
| 117 | 124 | // the last moved row no longer precedes the row below it. | |
| 118 | 125 | b.hard(top - 1) | |
| 119 | 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 | 128 | for y := top; y < top+n; y++ { | |
| 121 | 129 | b.boundaries[y] = LineBound{} | |
| 130 | + | b.rowIDs[y] = 0 | |
| 122 | 131 | } | |
| 123 | 132 | b.hard(bottom) | |
| 124 | 133 | } | |
| @@ -150,7 +159,7 @@ func (b *buffer) hasSoft() bool { | |||
| 150 | 159 | ||
| 151 | 160 | // resizeFixed keeps hard physical lines independent. It is the common shell | |
| 152 | 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 | 163 | anchor := 0 | |
| 155 | 164 | if active != nil { | |
| 156 | 165 | anchor = active.row | |
| @@ -158,10 +167,12 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([] | |||
| 158 | 167 | shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0)) | |
| 159 | 168 | evicted := make([][]renderer.Cell, 0, shift) | |
| 160 | 169 | evictedBounds := make([]LineBound, 0, shift) | |
| 170 | + | evictedIDs := make([]RowID, 0, shift) | |
| 161 | 171 | for y := range shift { | |
| 162 | 172 | evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...)) | |
| 163 | 173 | // The evicted row keeps its source width, so its extent needs no clamping. | |
| 164 | 174 | evictedBounds = append(evictedBounds, b.boundaries[y]) | |
| 175 | + | evictedIDs = append(evictedIDs, b.rowIDs[y]) | |
| 165 | 176 | } | |
| 166 | 177 | // Fixed-line resizes can retain the boundary backing store. In particular, | |
| 167 | 178 | // 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 | 183 | } else { | |
| 173 | 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 | 187 | copied := 0 | |
| 177 | 188 | for y := range height { | |
| 178 | 189 | sy := y + shift | |
| @@ -181,6 +192,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([] | |||
| 181 | 192 | } | |
| 182 | 193 | copy(next.frame.Row(y), b.frame.Row(sy)) | |
| 183 | 194 | next.boundaries[y] = b.boundaries[sy] | |
| 195 | + | next.rowIDs[y] = b.rowIDs[sy] | |
| 184 | 196 | next.boundaries[y].End = min(next.boundaries[y].End, width) | |
| 185 | 197 | repairFrameRow(next.frame, y) | |
| 186 | 198 | copied++ | |
| @@ -193,7 +205,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([] | |||
| 193 | 205 | } | |
| 194 | 206 | } | |
| 195 | 207 | *b = next | |
| 196 | - | return evicted, evictedBounds | |
| 208 | + | return evicted, evictedBounds, evictedIDs | |
| 197 | 209 | } | |
| 198 | 210 | ||
| 199 | 211 | type reflowPoint struct { | |
| @@ -234,7 +246,7 @@ func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint | |||
| 234 | 246 | // the first maps both cursors and counts rows; the second writes only the | |
| 235 | 247 | // retained viewport (and the rows genuinely evicted to history). It never | |
| 236 | 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 | 250 | if !b.hasSoft() { | |
| 239 | 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 | 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 | 264 | anchor := 0 | |
| 253 | 265 | if active != nil { | |
| 254 | 266 | anchor = points[0].row | |
| @@ -259,17 +271,19 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]ren | |||
| 259 | 271 | var evicted [][]renderer.Cell | |
| 260 | 272 | var evictedCells []renderer.Cell | |
| 261 | 273 | var evictedBounds []LineBound | |
| 274 | + | var evictedIDs []RowID | |
| 262 | 275 | if shift > 0 { | |
| 263 | 276 | // History needs owned rows. One flat backing store replaces a temporary | |
| 264 | 277 | // allocation per evicted output row. | |
| 265 | 278 | evicted = make([][]renderer.Cell, shift) | |
| 266 | 279 | evictedCells = make([]renderer.Cell, shift*width) | |
| 267 | 280 | evictedBounds = make([]LineBound, shift) | |
| 281 | + | evictedIDs = make([]RowID, shift) | |
| 268 | 282 | for y := range evicted { | |
| 269 | 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 | 287 | for i, cur := range [2]*bufferCursor{active, saved} { | |
| 274 | 288 | if cur != nil { | |
| 275 | 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 | 293 | *b = *next | |
| 280 | - | return evicted, evictedBounds | |
| 294 | + | return evicted, evictedBounds, evictedIDs | |
| 281 | 295 | } | |
| 282 | 296 | ||
| 283 | 297 | // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in | |
| 284 | 298 | // [shift, shift+dst.height). Rows before shift are written straight into the | |
| 285 | 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 | 301 | 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 | 315 | blankEvicted := func(outputRow int) []renderer.Cell { | |
| 289 | 316 | if outputRow < shift { | |
| 290 | 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 | 370 | reflow := end > start | |
| 344 | 371 | 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 | 382 | truncated := false | |
| 346 | 383 | for y := start; y <= end && !truncated; y++ { | |
| 347 | 384 | 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.Frame | 9 | frame renderer.Frame | ||
| 10 | boundaries []LineBound | 10 | boundaries []LineBound | ||
| 11 | + | rowIDs []RowID | |||
| 11 | } | 12 | } | ||
| 12 | 13 | ||||
| 13 | // LineBound describes a physical row's logical extent. End is exclusive: it is | 14 | // 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 out | 37 | 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 shell | 160 | // 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 := 0 | 163 | anchor := 0 | ||
| 155 | if active != nil { | 164 | if active != nil { | ||
| 156 | anchor = active.row | 165 | 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/grow | 178 | // 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 := 0 | 187 | copied := 0 | ||
| 177 | for y := range height { | 188 | for y := range height { | ||
| 178 | sy := y + shift | 189 | 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 = next | 207 | *b = next | ||
| 196 | - | return evicted, evictedBounds | 208 | + | 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 the | 246 | // the first maps both cursors and counts rows; the second writes only the | ||
| 235 | // retained viewport (and the rows genuinely evicted to history). It never | 247 | // 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 := 0 | 264 | anchor := 0 | ||
| 253 | if active != nil { | 265 | if active != nil { | ||
| 254 | anchor = points[0].row | 266 | anchor = points[0].row | ||
| @@ -259,17 +271,19 @@ func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]ren | |||||
| 259 | var evicted [][]renderer.Cell | 271 | var evicted [][]renderer.Cell | ||
| 260 | var evictedCells []renderer.Cell | 272 | var evictedCells []renderer.Cell | ||
| 261 | var evictedBounds []LineBound | 273 | 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 temporary | 276 | // 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 = *next | 293 | *b = *next | ||
| 280 | - | return evicted, evictedBounds | 294 | + | return evicted, evictedBounds, evictedIDs |
| 281 | } | 295 | } | ||
| 282 | 296 | ||||
| 283 | // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in | 297 | // 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 the | 298 | // [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, 0 | 301 | 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 > start | 370 | reflow := end > start | ||
| 344 | offset := 0 | 371 | 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 := false | 382 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | "math" | |
| 6 | + | "slices" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/vev/pkg/renderer" | |
| 8 | 9 | ) | |
| @@ -11,6 +12,8 @@ import ( | |||
| 11 | 12 | // cell budget. The history is not modified when this error is returned. | |
| 12 | 13 | var ErrHistoryRowTooWide = errors.New("history row exceeds cell capacity") | |
| 13 | 14 | ||
| 15 | + | var errInvalidHistoryRowID = errors.New("invalid history row ID") | |
| 16 | + | ||
| 14 | 17 | // HistoryConfig controls the bounded terminal history retained by a Screen. | |
| 15 | 18 | type HistoryConfig struct { | |
| 16 | 19 | MaxRows int | |
| @@ -28,8 +31,10 @@ type History struct { | |||
| 28 | 31 | chunks []*HistoryChunk | |
| 29 | 32 | tail [][]renderer.Cell | |
| 30 | 33 | tailBounds []LineBound | |
| 34 | + | tailIDs []RowID | |
| 31 | 35 | rows int | |
| 32 | 36 | cells int | |
| 37 | + | nextRowID RowID | |
| 33 | 38 | } | |
| 34 | 39 | ||
| 35 | 40 | // HistoryChunk is an immutable group of history rows. Its identity is stable | |
| @@ -38,14 +43,16 @@ type History struct { | |||
| 38 | 43 | type HistoryChunk struct { | |
| 39 | 44 | rows [][]renderer.Cell | |
| 40 | 45 | bounds []LineBound | |
| 46 | + | rowIDs []RowID | |
| 41 | 47 | } | |
| 42 | 48 | ||
| 43 | 49 | // HistoryView is an immutable snapshot of history. Row returns a copy so the | |
| 44 | 50 | // storage behind a sealed chunk remains owned by VT. | |
| 45 | 51 | type HistoryView struct { | |
| 46 | - | chunks []*HistoryChunk | |
| 47 | - | rows int | |
| 48 | - | cells int | |
| 52 | + | chunks []*HistoryChunk | |
| 53 | + | rows int | |
| 54 | + | cells int | |
| 55 | + | nextRowID RowID | |
| 49 | 56 | } | |
| 50 | 57 | ||
| 51 | 58 | // HistorySnapshotView captures sealed history chunks and the mutable tail | |
| @@ -55,8 +62,10 @@ type HistorySnapshotView struct { | |||
| 55 | 62 | chunks []*HistoryChunk | |
| 56 | 63 | tail [][]renderer.Cell | |
| 57 | 64 | tailBounds []LineBound | |
| 65 | + | tailIDs []RowID | |
| 58 | 66 | rows int | |
| 59 | 67 | cells int | |
| 68 | + | nextRowID RowID | |
| 60 | 69 | } | |
| 61 | 70 | ||
| 62 | 71 | func NewHistory(config HistoryConfig) *History { | |
| @@ -73,7 +82,7 @@ func NewHistory(config HistoryConfig) *History { | |||
| 73 | 82 | if maxCells <= 0 { | |
| 74 | 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 | 88 | func defaultHistoryMaxCells(maxRows int) int { | |
| @@ -83,9 +92,10 @@ func defaultHistoryMaxCells(maxRows int) int { | |||
| 83 | 92 | return maxRows * 160 | |
| 84 | 93 | } | |
| 85 | 94 | ||
| 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. | |
| 89 | 99 | func (h *History) Append(row []renderer.Cell, bound LineBound) error { | |
| 90 | 100 | if h == nil || h.maxRows == 0 { | |
| 91 | 101 | return nil | |
| @@ -93,11 +103,50 @@ func (h *History) Append(row []renderer.Cell, bound LineBound) error { | |||
| 93 | 103 | if len(row) > h.maxCells { | |
| 94 | 104 | 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 | 144 | // Make room before adding so Cells cannot overflow even when the default | |
| 97 | 145 | // capacity is MaxInt. | |
| 98 | 146 | h.evictFor(len(row)) | |
| 99 | 147 | h.tail = append(h.tail, append([]renderer.Cell(nil), row...)) | |
| 100 | 148 | h.tailBounds = append(h.tailBounds, clampBound(bound, len(row))) | |
| 149 | + | h.tailIDs = append(h.tailIDs, id) | |
| 101 | 150 | h.rows++ | |
| 102 | 151 | h.cells += len(row) | |
| 103 | 152 | if len(h.tail) == h.chunkRows { | |
| @@ -106,6 +155,29 @@ func (h *History) Append(row []renderer.Cell, bound LineBound) error { | |||
| 106 | 155 | 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 | 181 | // clampBound keeps End inside the row it describes so a persisted bound can be | |
| 110 | 182 | // validated without consulting its row again. | |
| 111 | 183 | func clampBound(b LineBound, cells int) LineBound { | |
| @@ -124,26 +196,50 @@ func growBounds(bounds []LineBound, rows int) []LineBound { | |||
| 124 | 196 | 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 | 214 | func (h *History) sealTail() { | |
| 128 | 215 | if len(h.tail) == 0 { | |
| 129 | 216 | 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 | 223 | h.tail = nil | |
| 133 | 224 | h.tailBounds = nil | |
| 225 | + | h.tailIDs = nil | |
| 134 | 226 | } | |
| 135 | 227 | ||
| 136 | 228 | // normalizeTail seals complete chunks so the mutable tail remains shorter than | |
| 137 | 229 | // chunkRows. Restored snapshots may have been written with a different chunk | |
| 138 | 230 | // size than the current history configuration. | |
| 139 | 231 | func (h *History) normalizeTail() { | |
| 232 | + | h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)) | |
| 140 | 233 | for h.chunkRows > 0 && len(h.tail) >= h.chunkRows { | |
| 234 | + | bounds := growBounds(h.tailBounds, len(h.tail)) | |
| 141 | 235 | h.chunks = append(h.chunks, &HistoryChunk{ | |
| 142 | 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 | 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 | 268 | } else { | |
| 173 | 269 | // Preserve cell storage while replacing only the chunk wrapper: a | |
| 174 | 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 | 279 | continue | |
| 178 | 280 | } | |
| @@ -185,6 +287,7 @@ func (h *History) evictUntil(rowCount, cellCount int) { | |||
| 185 | 287 | h.tail[0] = nil | |
| 186 | 288 | h.tail = h.tail[1:] | |
| 187 | 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 | 303 | // View captures the current history. Sealed chunks are shared by identity; a | |
| 201 | 304 | // partially-filled tail is copied into a new immutable chunk for this view. | |
| 202 | 305 | func (h *History) View() HistoryView { | |
| 203 | - | if h == nil || h.rows == 0 { | |
| 306 | + | if h == nil { | |
| 204 | 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 | 314 | if len(h.tail) > 0 { | |
| 208 | 315 | chunks = append(chunks, &HistoryChunk{ | |
| 209 | 316 | rows: cloneHistoryRows(h.tail), | |
| 210 | 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 | 324 | // SnapshotView captures history for persistence without sealing the mutable | |
| 217 | 325 | // tail. Sealed chunks are shared by identity and the tail is deeply copied. | |
| 218 | 326 | func (h *History) SnapshotView() HistorySnapshotView { | |
| 219 | - | if h == nil || h.rows == 0 { | |
| 327 | + | if h == nil { | |
| 220 | 328 | return HistorySnapshotView{} | |
| 221 | 329 | } | |
| 330 | + | if h.rows == 0 { | |
| 331 | + | return HistorySnapshotView{nextRowID: h.nextRowID} | |
| 332 | + | } | |
| 222 | 333 | return HistorySnapshotView{ | |
| 223 | 334 | chunks: append([]*HistoryChunk(nil), h.chunks...), | |
| 224 | 335 | tail: cloneHistoryRows(h.tail), | |
| 225 | 336 | tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...), | |
| 337 | + | tailIDs: cloneRowIDs(h.tailIDs, len(h.tail)), | |
| 226 | 338 | rows: h.rows, | |
| 227 | 339 | cells: h.cells, | |
| 340 | + | nextRowID: h.nextRowID, | |
| 228 | 341 | } | |
| 229 | 342 | } | |
| 230 | 343 | ||
| @@ -255,6 +368,14 @@ func (h *History) Cells() int { | |||
| 255 | 368 | 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 | 379 | // Cap returns the configured bounded row capacity. | |
| 259 | 380 | func (h *History) Cap() int { | |
| 260 | 381 | if h == nil { | |
| @@ -275,6 +396,18 @@ func (v HistoryView) Len() int { return v.rows } | |||
| 275 | 396 | func (v HistoryView) Cells() int { return v.cells } | |
| 276 | 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 | 411 | // Chunk returns the immutable chunk at i, or nil when i is out of range. | |
| 279 | 412 | func (v HistoryView) Chunk(i int) *HistoryChunk { | |
| 280 | 413 | if i < 0 || i >= len(v.chunks) { | |
| @@ -316,6 +449,48 @@ func (v HistoryView) BorrowedRow(i int) []renderer.Cell { | |||
| 316 | 449 | 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 | 494 | // Bound returns the logical extent of the row at i, or the zero value when i is | |
| 320 | 495 | // out of range. A zero value describes a hard row whose End is not meaningful. | |
| 321 | 496 | func (v HistoryView) Bound(i int) LineBound { | |
| @@ -338,6 +513,14 @@ func (v HistorySnapshotView) Len() int { return v.rows } | |||
| 338 | 513 | func (v HistorySnapshotView) Cells() int { return v.cells } | |
| 339 | 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 | 524 | // Chunk returns a sealed immutable chunk at i, or nil when i is out of range. | |
| 342 | 525 | func (v HistorySnapshotView) Chunk(i int) *HistoryChunk { | |
| 343 | 526 | if i < 0 || i >= len(v.chunks) { | |
| @@ -349,12 +532,13 @@ func (v HistorySnapshotView) Chunk(i int) *HistoryChunk { | |||
| 349 | 532 | // Tail returns an immutable view of the copied mutable tail. | |
| 350 | 533 | func (v HistorySnapshotView) Tail() HistoryView { | |
| 351 | 534 | if len(v.tail) == 0 { | |
| 352 | - | return HistoryView{} | |
| 535 | + | return HistoryView{nextRowID: v.nextRowID} | |
| 353 | 536 | } | |
| 354 | 537 | 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, | |
| 358 | 542 | } | |
| 359 | 543 | } | |
| 360 | 544 | ||
| @@ -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 int | 19 | MaxRows int | ||
| @@ -28,8 +31,10 @@ type History struct { | |||||
| 28 | chunks []*HistoryChunk | 31 | chunks []*HistoryChunk | ||
| 29 | tail [][]renderer.Cell | 32 | tail [][]renderer.Cell | ||
| 30 | tailBounds []LineBound | 33 | tailBounds []LineBound | ||
| 34 | + | tailIDs []RowID | |||
| 31 | rows int | 35 | rows int | ||
| 32 | cells int | 36 | cells int | ||
| 37 | + | nextRowID RowID | |||
| 33 | } | 38 | } | ||
| 34 | 39 | ||||
| 35 | // HistoryChunk is an immutable group of history rows. Its identity is stable | 40 | // 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.Cell | 44 | rows [][]renderer.Cell | ||
| 40 | bounds []LineBound | 45 | bounds []LineBound | ||
| 46 | + | rowIDs []RowID | |||
| 41 | } | 47 | } | ||
| 42 | 48 | ||||
| 43 | // HistoryView is an immutable snapshot of history. Row returns a copy so the | 49 | // 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 []*HistoryChunk | 52 | + | chunks []*HistoryChunk |
| 47 | - | rows int | 53 | + | rows int |
| 48 | - | cells int | 54 | + | cells int |
| 55 | + | nextRowID RowID | |||
| 49 | } | 56 | } | ||
| 50 | 57 | ||||
| 51 | // HistorySnapshotView captures sealed history chunks and the mutable tail | 58 | // HistorySnapshotView captures sealed history chunks and the mutable tail | ||
| @@ -55,8 +62,10 @@ type HistorySnapshotView struct { | |||||
| 55 | chunks []*HistoryChunk | 62 | chunks []*HistoryChunk | ||
| 56 | tail [][]renderer.Cell | 63 | tail [][]renderer.Cell | ||
| 57 | tailBounds []LineBound | 64 | tailBounds []LineBound | ||
| 65 | + | tailIDs []RowID | |||
| 58 | rows int | 66 | rows int | ||
| 59 | cells int | 67 | 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 * 160 | 92 | return maxRows * 160 | ||
| 84 | } | 93 | } | ||
| 85 | 94 | ||||
| 86 | - | // Append records a copy of row along with its logical extent. Once a chunk is | 95 | + | // 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 are | 96 | + | // 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 nil | 101 | 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 ErrHistoryRowTooWide | 104 | 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 default | 144 | // 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 nil | 155 | 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 be | 181 | // 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 out | 196 | 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 | return | 216 | 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 = nil | 223 | h.tail = nil | ||
| 133 | h.tailBounds = nil | 224 | h.tailBounds = nil | ||
| 225 | + | h.tailIDs = nil | |||
| 134 | } | 226 | } | ||
| 135 | 227 | ||||
| 136 | // normalizeTail seals complete chunks so the mutable tail remains shorter than | 228 | // normalizeTail seals complete chunks so the mutable tail remains shorter than | ||
| 137 | // chunkRows. Restored snapshots may have been written with a different chunk | 229 | // 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: a | 269 | // 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 | continue | 279 | continue | ||
| 178 | } | 280 | } | ||
| @@ -185,6 +287,7 @@ func (h *History) evictUntil(rowCount, cellCount int) { | |||||
| 185 | h.tail[0] = nil | 287 | 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; a | 303 | // 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 mutable | 324 | // 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.cells | 368 | 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 nil | 449 | 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 is | 494 | // 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 ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "math" | |
| 8 | + | "slices" | |
| 8 | 9 | "unicode/utf8" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/vev/pkg/renderer" | |
| @@ -12,7 +13,7 @@ import ( | |||
| 12 | 13 | ||
| 13 | 14 | const ( | |
| 14 | 15 | historyMagic = "VTH1" | |
| 15 | - | historyVersion = 2 | |
| 16 | + | historyVersion = 3 | |
| 16 | 17 | historyCellBytes = 41 | |
| 17 | 18 | historyBoundBytes = 5 | |
| 18 | 19 | maxHistoryChunkRows = 256 | |
| @@ -37,13 +38,29 @@ func MarshalHistory(view HistoryView) ([]byte, error) { | |||
| 37 | 38 | if view.rows < 0 || view.rows != historyViewRowCount(view) || uint64(len(view.chunks)) > math.MaxUint32 || uint64(len(view.chunks)) > maxHistoryChunks { | |
| 38 | 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 | 45 | stats := historyDecodeStats{} | |
| 46 | + | seen := make(map[RowID]struct{}, view.rows) | |
| 41 | 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 | 49 | !addHistoryDecodeBudget(&stats.rows, uint64(len(chunk.rows)), maxHistoryRows) { | |
| 44 | 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 | 64 | cellCount := uint64(len(row)) | |
| 48 | 65 | cellBytes, ok := historyCellByteCount(cellCount) | |
| 49 | 66 | if !ok || | |
| @@ -52,39 +69,80 @@ func MarshalHistory(view HistoryView) ([]byte, error) { | |||
| 52 | 69 | !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) { | |
| 53 | 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 | 88 | out = append(out, historyMagic...) | |
| 60 | 89 | out = append(out, historyVersion) | |
| 61 | 90 | out = appendUint32(out, uint32(len(view.chunks))) | |
| 91 | + | out = binary.BigEndian.AppendUint64(out, uint64(nextRowID)) | |
| 62 | 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 | 93 | out = appendUint32(out, uint32(len(chunk.rows))) | |
| 67 | 94 | 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])) | |
| 75 | 96 | out = appendUint32(out, uint32(len(row))) | |
| 76 | 97 | for _, cell := range row { | |
| 77 | - | if !validHistoryCell(cell) { | |
| 78 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 79 | - | } | |
| 80 | 98 | out = appendHistoryCell(out, cell) | |
| 81 | 99 | } | |
| 82 | - | out = appendHistoryBound(out, bound) | |
| 100 | + | out = appendHistoryBound(out, chunkBound(chunk, i)) | |
| 83 | 101 | } | |
| 84 | 102 | } | |
| 85 | 103 | 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 | 146 | func historyViewRowCount(view HistoryView) int { | |
| 89 | 147 | n := 0 | |
| 90 | 148 | for _, chunk := range view.chunks { | |
| @@ -189,7 +247,7 @@ func preflightHistory(data []byte) (historyDecodeStats, bool) { | |||
| 189 | 247 | // parseHistory is the authoritative history payload validation path. populate | |
| 190 | 248 | // controls whether validated cells are retained in a HistoryView. | |
| 191 | 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 | 251 | return HistoryView{}, historyDecodeStats{}, false | |
| 194 | 252 | } | |
| 195 | 253 | ||
| @@ -198,7 +256,14 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||
| 198 | 256 | if !ok || uint64(chunkCount) > maxHistoryChunks || uint64(chunkCount) > uint64(len(p.data))/4 { | |
| 199 | 257 | 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 | 263 | stats := historyDecodeStats{chunks: uint64(chunkCount)} | |
| 264 | + | var seenIDs [maxHistoryRows]RowID | |
| 265 | + | seenCount := 0 | |
| 266 | + | var maxID RowID | |
| 202 | 267 | var chunks []*HistoryChunk | |
| 203 | 268 | if populate { | |
| 204 | 269 | chunks = make([]*HistoryChunk, 0, chunkCount) | |
| @@ -206,18 +271,30 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||
| 206 | 271 | for range chunkCount { | |
| 207 | 272 | rowCount, ok := p.uint32() | |
| 208 | 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 | 275 | !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) { | |
| 211 | 276 | return HistoryView{}, historyDecodeStats{}, false | |
| 212 | 277 | } | |
| 213 | 278 | ||
| 214 | 279 | var rows [][]renderer.Cell | |
| 215 | 280 | var bounds []LineBound | |
| 281 | + | var rowIDs []RowID | |
| 216 | 282 | if populate { | |
| 217 | 283 | rows = make([][]renderer.Cell, 0, rowCount) | |
| 218 | 284 | bounds = make([]LineBound, 0, rowCount) | |
| 285 | + | rowIDs = make([]RowID, 0, rowCount) | |
| 219 | 286 | } | |
| 220 | 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 | 298 | cellCount, ok := p.uint32() | |
| 222 | 299 | cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount)) | |
| 223 | 300 | if !ok || !validCellBytes || | |
| @@ -248,16 +325,23 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||
| 248 | 325 | if populate { | |
| 249 | 326 | rows = append(rows, row) | |
| 250 | 327 | bounds = append(bounds, bound) | |
| 328 | + | rowIDs = append(rowIDs, rowID) | |
| 251 | 329 | } | |
| 252 | 330 | } | |
| 253 | 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 | 342 | return HistoryView{}, historyDecodeStats{}, false | |
| 259 | 343 | } | |
| 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 | |
| 261 | 345 | } | |
| 262 | 346 | ||
| 263 | 347 | func historyCellByteCount(cellCount uint64) (uint64, bool) { | |
| @@ -286,6 +370,15 @@ func (p *historyParser) uint32() (uint32, bool) { | |||
| 286 | 370 | 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 | 382 | func (p *historyParser) cell() (renderer.Cell, bool) { | |
| 290 | 383 | if len(p.data) < historyCellBytes { | |
| 291 | 384 | 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 = 2 | 16 | + | historyVersion = 3 |
| 16 | historyCellBytes = 41 | 17 | historyCellBytes = 41 | ||
| 17 | historyBoundBytes = 5 | 18 | historyBoundBytes = 5 | ||
| 18 | maxHistoryChunkRows = 256 | 19 | 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, nil | 103 | 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 := 0 | 147 | 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. populate | 247 | // 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{}, false | 251 | 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{}, false | 257 | 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 []*HistoryChunk | 267 | 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{}, false | 276 | return HistoryView{}, historyDecodeStats{}, false | ||
| 212 | } | 277 | } | ||
| 213 | 278 | ||||
| 214 | var rows [][]renderer.Cell | 279 | var rows [][]renderer.Cell | ||
| 215 | var bounds []LineBound | 280 | 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{}, false | 342 | return HistoryView{}, historyDecodeStats{}, false | ||
| 259 | } | 343 | } | ||
| 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 |
| 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, true | 370 | 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{}, false | 384 | return renderer.Cell{}, false | ||
M pkg/vt/history_codec_bound_test.go +4 -3
| @@ -10,9 +10,10 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | func TestHistoryCodecRejectsMalformedBounds(t *testing.T) { | |
| 12 | 12 | 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, | |
| 16 | 17 | }) | |
| 17 | 18 | require.NoError(t, err) | |
| 18 | 19 | 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) - historyBoundBytes | 19 | boundOffset := len(blob) - historyBoundBytes | ||
M pkg/vt/history_codec_test.go +45 -45
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | "github.com/bnema/vev/pkg/renderer" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | - | func TestHistoryCodecV2Bytes(t *testing.T) { | |
| 13 | + | func TestHistoryCodecV3Bytes(t *testing.T) { | |
| 14 | 14 | h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2}) | |
| 15 | 15 | if err := h.Append([]renderer.Cell{{Rune: 'a'}}, LineBound{End: 1, Soft: true}); err != nil { | |
| 16 | 16 | t.Fatalf("append: %v", err) | |
| @@ -21,9 +21,11 @@ func TestHistoryCodecV2Bytes(t *testing.T) { | |||
| 21 | 21 | } | |
| 22 | 22 | ||
| 23 | 23 | want := []byte{ | |
| 24 | - | 'V', 'T', 'H', '1', 2, // magic, version | |
| 24 | + | 'V', 'T', 'H', '1', 3, // magic, version | |
| 25 | 25 | 0, 0, 0, 1, // chunk count | |
| 26 | + | 0, 0, 0, 0, 0, 0, 0, 2, // next row ID | |
| 26 | 27 | 0, 0, 0, 1, // row count | |
| 28 | + | 0, 0, 0, 0, 0, 0, 0, 1, // row ID | |
| 27 | 29 | 0, 0, 0, 1, // cell count | |
| 28 | 30 | 0, 0, 0, 'a', // Rune | |
| 29 | 31 | 0, // flags | |
| @@ -65,12 +67,12 @@ func TestHistoryCodecRoundTripsBounds(t *testing.T) { | |||
| 65 | 67 | } | |
| 66 | 68 | } | |
| 67 | 69 | ||
| 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{ | |
| 71 | 73 | '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', | |
| 74 | 76 | 0, 0, 0, | |
| 75 | 77 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, | |
| 76 | 78 | 0, 0, 0, 0, 0, 0, | |
| @@ -79,23 +81,13 @@ func TestHistoryCodecRejectsMalformedAndV1Payloads(t *testing.T) { | |||
| 79 | 81 | 0, 0, 0, | |
| 80 | 82 | 0, 0, 0, 1, 1, | |
| 81 | 83 | } | |
| 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) | |
| 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 | 93 | func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) { | |
| @@ -171,21 +163,13 @@ func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) { | |||
| 171 | 163 | t.Fatalf("marshal history: %v", err) | |
| 172 | 164 | } | |
| 173 | 165 | ||
| 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 | + | } | |
| 181 | 170 | } | |
| 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") | |
| 189 | 173 | } | |
| 190 | 174 | } | |
| 191 | 175 | ||
| @@ -300,31 +284,41 @@ func historyViewWithDimensions(rowCount, width int) HistoryView { | |||
| 300 | 284 | row[i].Rune = 'x' | |
| 301 | 285 | } | |
| 302 | 286 | chunks := make([]*HistoryChunk, 0, (rowCount+maxHistoryChunkRows-1)/maxHistoryChunkRows) | |
| 287 | + | rowID := RowID(1) | |
| 303 | 288 | for rowCount > 0 { | |
| 304 | 289 | chunkRows := min(rowCount, maxHistoryChunkRows) | |
| 305 | 290 | rows := make([][]renderer.Cell, chunkRows) | |
| 291 | + | rowIDs := make([]RowID, chunkRows) | |
| 306 | 292 | for i := range rows { | |
| 307 | 293 | 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 | 298 | 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 | 303 | func historyPayloadWithDimensions(rowCount, width int) []byte { | |
| 316 | - | data := make([]byte, 9) | |
| 304 | + | data := make([]byte, 17) | |
| 317 | 305 | copy(data, historyMagic) | |
| 318 | 306 | data[4] = historyVersion | |
| 319 | 307 | chunkCount := (rowCount + maxHistoryChunkRows - 1) / maxHistoryChunkRows | |
| 320 | 308 | binary.BigEndian.PutUint32(data[5:], uint32(chunkCount)) | |
| 309 | + | binary.BigEndian.PutUint64(data[9:], uint64(rowCount+1)) | |
| 310 | + | rowID := uint64(1) | |
| 321 | 311 | for rowCount > 0 { | |
| 322 | 312 | chunkRows := min(rowCount, maxHistoryChunkRows) | |
| 323 | 313 | data = binary.BigEndian.AppendUint32(data, uint32(chunkRows)) | |
| 324 | 314 | for range chunkRows { | |
| 315 | + | data = binary.BigEndian.AppendUint64(data, rowID) | |
| 316 | + | rowID++ | |
| 325 | 317 | data = binary.BigEndian.AppendUint32(data, uint32(width)) | |
| 326 | 318 | //nolint:makezero // The header prefix is retained while zeroed cell records are appended. | |
| 327 | 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 | 323 | rowCount -= chunkRows | |
| 330 | 324 | } | |
| @@ -335,22 +329,28 @@ func historyPayloadWithDimensions(rowCount, width int) []byte { | |||
| 335 | 329 | // budget, but its cells exceed the aggregate budget. It deliberately omits | |
| 336 | 330 | // cell payload bytes: the aggregate limit must reject it before allocation. | |
| 337 | 331 | func aggregateCellLimitDeclaration() []byte { | |
| 338 | - | data := make([]byte, 9, 21) | |
| 332 | + | data := make([]byte, 17, 29) | |
| 339 | 333 | copy(data, historyMagic) | |
| 340 | 334 | data[4] = historyVersion | |
| 341 | 335 | binary.BigEndian.PutUint32(data[5:], 1) | |
| 336 | + | binary.BigEndian.PutUint64(data[9:], 2) | |
| 342 | 337 | data = binary.BigEndian.AppendUint32(data, 1) | |
| 338 | + | data = binary.BigEndian.AppendUint64(data, 1) | |
| 343 | 339 | return binary.BigEndian.AppendUint32(data, uint32(maxHistoryCells+1)) | |
| 344 | 340 | } | |
| 345 | 341 | ||
| 346 | 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 | 344 | copy(data, historyMagic) | |
| 349 | 345 | data[4] = historyVersion | |
| 350 | 346 | binary.BigEndian.PutUint32(data[5:], uint32(chunkCount)) | |
| 347 | + | binary.BigEndian.PutUint64(data[9:], uint64(chunkCount*rowCount+1)) | |
| 348 | + | id := uint64(1) | |
| 351 | 349 | for range chunkCount { | |
| 352 | 350 | data = binary.BigEndian.AppendUint32(data, uint32(rowCount)) | |
| 353 | 351 | for range rowCount { | |
| 352 | + | data = binary.BigEndian.AppendUint64(data, id) | |
| 353 | + | id++ | |
| 354 | 354 | data = binary.BigEndian.AppendUint32(data, 0) | |
| 355 | 355 | } | |
| 356 | 356 | } | |
| @@ -363,9 +363,9 @@ func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) { | |||
| 363 | 363 | t.Fatalf("marshal valid history: %v", err) | |
| 364 | 364 | } | |
| 365 | 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 | 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 | 370 | tests := []struct { | |
| 371 | 371 | name string | |
| @@ -379,7 +379,7 @@ func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) { | |||
| 379 | 379 | {name: "invalid underline style", data: invalidUnderlineStyle}, | |
| 380 | 380 | {name: "aggregate row budget", data: hostileHistoryDeclarations(47, maxHistoryChunkRows)}, | |
| 381 | 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 | 384 | for _, tt := range tests { | |
| 385 | 385 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -398,7 +398,7 @@ func FuzzHistoryPreflightMatchesUnmarshal(f *testing.F) { | |||
| 398 | 398 | f.Fatalf("marshal valid history: %v", err) | |
| 399 | 399 | } | |
| 400 | 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 | 402 | f.Add(valid) | |
| 403 | 403 | f.Add(invalidRune) | |
| 404 | 404 | 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, version | 24 | + | 'V', 'T', 'H', '1', 3, // magic, version |
| 25 | 0, 0, 0, 1, // chunk count | 25 | 0, 0, 0, 1, // chunk count | ||
| 26 | + | 0, 0, 0, 0, 0, 0, 0, 2, // next row ID | |||
| 26 | 0, 0, 0, 1, // row count | 27 | 0, 0, 0, 1, // row count | ||
| 28 | + | 0, 0, 0, 0, 0, 0, 0, 1, // row ID | |||
| 27 | 0, 0, 0, 1, // cell count | 29 | 0, 0, 0, 1, // cell count | ||
| 28 | 0, 0, 0, 'a', // Rune | 30 | 0, 0, 0, 'a', // Rune | ||
| 29 | 0, // flags | 31 | 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 bound | 85 | + | 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] = 1 | 88 | + | 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 string | 167 | + | if _, err := UnmarshalHistory(encoded[:i]); err == nil { |
| 176 | - | data []byte | 168 | + | 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] = row | 293 | 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 -= chunkRows | 298 | 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] = historyVersion | 306 | data[4] = historyVersion | ||
| 319 | chunkCount := (rowCount + maxHistoryChunkRows - 1) / maxHistoryChunkRows | 307 | 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 -= chunkRows | 323 | 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 omits | 329 | // 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] = historyVersion | 334 | 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] = historyVersion | 345 | 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 string | 371 | 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" | |||
| 5 | 5 | // RecoveryTranscriptSnapshot is an owned, immutable capture of the viewport | |
| 6 | 6 | // rows that should be replayed after retained terminal history during recovery. | |
| 7 | 7 | type RecoveryTranscriptSnapshot struct { | |
| 8 | - | segments []recoveryTranscriptSegment | |
| 8 | + | segments []recoveryTranscriptSegment | |
| 9 | + | nextRowID RowID | |
| 9 | 10 | } | |
| 10 | 11 | ||
| 11 | 12 | type recoveryTranscriptSegment struct { | |
| 12 | 13 | rows [][]renderer.Cell | |
| 13 | 14 | bounds []LineBound | |
| 15 | + | rowIDs []RowID | |
| 14 | 16 | } | |
| 15 | 17 | ||
| 16 | 18 | // RecoveryTranscriptSnapshot captures the active primary viewport, or the | |
| @@ -25,7 +27,11 @@ func (s *Screen) RecoveryTranscriptSnapshot() RecoveryTranscriptSnapshot { | |||
| 25 | 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 | 35 | for _, b := range buffers { | |
| 30 | 36 | segment := captureRecoveryTranscriptSegment(b) | |
| 31 | 37 | if len(segment.rows) > 0 { | |
| @@ -51,6 +57,7 @@ func captureRecoveryTranscriptSegment(b *buffer) recoveryTranscriptSegment { | |||
| 51 | 57 | segment := recoveryTranscriptSegment{ | |
| 52 | 58 | rows: make([][]renderer.Cell, rowCount), | |
| 53 | 59 | bounds: append([]LineBound(nil), b.boundaries[:rowCount]...), | |
| 60 | + | rowIDs: append([]RowID(nil), b.rowIDs[:rowCount]...), | |
| 54 | 61 | } | |
| 55 | 62 | cells := make([]renderer.Cell, rowCount*b.frame.Width) | |
| 56 | 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 | 99 | for _, segment := range snapshot.segments { | |
| 93 | 100 | for start := 0; start < len(segment.rows); { | |
| 94 | 101 | space := maxHistoryChunkRows | |
| @@ -99,6 +106,7 @@ func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) { | |||
| 99 | 106 | end := min(start+space, len(segment.rows)) | |
| 100 | 107 | last.rows = append(last.rows, segment.rows[start:end]...) | |
| 101 | 108 | last.bounds = append(last.bounds, segment.bounds[start:end]...) | |
| 109 | + | last.rowIDs = append(last.rowIDs, segment.rowIDs[start:end]...) | |
| 102 | 110 | start = end | |
| 103 | 111 | continue | |
| 104 | 112 | } | |
| @@ -108,6 +116,7 @@ func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) { | |||
| 108 | 116 | view.chunks = append(view.chunks, &HistoryChunk{ | |
| 109 | 117 | rows: append([][]renderer.Cell(nil), segment.rows[start:end]...), | |
| 110 | 118 | bounds: append([]LineBound(nil), segment.bounds[start:end]...), | |
| 119 | + | rowIDs: append([]RowID(nil), segment.rowIDs[start:end]...), | |
| 111 | 120 | }) | |
| 112 | 121 | start = end | |
| 113 | 122 | } | |
| @@ -5,12 +5,14 @@ import "github.com/bnema/vev/pkg/renderer" | |||||
| 5 | // RecoveryTranscriptSnapshot is an owned, immutable capture of the viewport | 5 | // 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 []recoveryTranscriptSegment | 8 | + | segments []recoveryTranscriptSegment |
| 9 | + | nextRowID RowID | |||
| 9 | } | 10 | } | ||
| 10 | 11 | ||||
| 11 | type recoveryTranscriptSegment struct { | 12 | type recoveryTranscriptSegment struct { | ||
| 12 | rows [][]renderer.Cell | 13 | rows [][]renderer.Cell | ||
| 13 | bounds []LineBound | 14 | bounds []LineBound | ||
| 15 | + | rowIDs []RowID | |||
| 14 | } | 16 | } | ||
| 15 | 17 | ||||
| 16 | // RecoveryTranscriptSnapshot captures the active primary viewport, or the | 18 | // 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 := maxHistoryChunkRows | 101 | 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 = end | 110 | start = end | ||
| 103 | continue | 111 | 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 = end | 121 | start = end | ||
| 113 | } | 122 | } | ||
M pkg/vt/recovery_transcript_test.go +7 -3
| @@ -188,7 +188,7 @@ func TestRecoveryTranscriptSnapshotMarshalsCanonicalEmptyHistory(t *testing.T) { | |||
| 188 | 188 | ||
| 189 | 189 | got, err := s.RecoveryTranscriptSnapshot().Marshal() | |
| 190 | 190 | require.NoError(t, err) | |
| 191 | - | want, err := MarshalHistory(HistoryView{}) | |
| 191 | + | want, err := MarshalHistory(HistoryView{nextRowID: 3}) | |
| 192 | 192 | require.NoError(t, err) | |
| 193 | 193 | ||
| 194 | 194 | require.Equal(t, want, got) | |
| @@ -396,9 +396,13 @@ func recoveryTranscriptBlob(t testing.TB, texts []string, bounds []LineBound) [] | |||
| 396 | 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 | 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 | 407 | blob, err := MarshalHistory(view) | |
| 404 | 408 | 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 { | |||
| 53 | 53 | history *History | |
| 54 | 54 | buffer *buffer | |
| 55 | 55 | ||
| 56 | + | nextRowID RowID | |
| 57 | + | ||
| 56 | 58 | defaultFG renderer.RGB | |
| 57 | 59 | defaultBG renderer.RGB | |
| 58 | 60 | defaultColorsKnown bool | |
| @@ -86,15 +88,14 @@ type Screen struct { | |||
| 86 | 88 | } | |
| 87 | 89 | ||
| 88 | 90 | func NewScreen(width, height int) *Screen { | |
| 89 | - | b := newBuffer(width, height) | |
| 90 | 91 | s := &Screen{ | |
| 91 | - | Frame: b.frame, | |
| 92 | - | buffer: b, | |
| 93 | 92 | Style: renderer.DefaultStyle(), | |
| 94 | 93 | damage: []renderer.Damage{renderer.FullRedraw()}, | |
| 95 | 94 | damageGeneration: 1, | |
| 96 | 95 | cursorVisible: true, | |
| 97 | 96 | } | |
| 97 | + | s.buffer = s.newBuffer(width, height) | |
| 98 | + | s.Frame = s.buffer.frame | |
| 98 | 99 | s.resetScrollRegion() | |
| 99 | 100 | return s | |
| 100 | 101 | } | |
| @@ -53,6 +53,8 @@ type Screen struct { | |||||
| 53 | history *History | 53 | history *History | ||
| 54 | buffer *buffer | 54 | buffer *buffer | ||
| 55 | 55 | ||||
| 56 | + | nextRowID RowID | |||
| 57 | + | ||||
| 56 | defaultFG renderer.RGB | 58 | defaultFG renderer.RGB | ||
| 57 | defaultBG renderer.RGB | 59 | defaultBG renderer.RGB | ||
| 58 | defaultColorsKnown bool | 60 | 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 s | 100 | return s | ||
| 100 | } | 101 | } | ||
M pkg/vt/screen_csi.go +1 -0
| @@ -412,6 +412,7 @@ func (s *Screen) clearScreenMode(mode int) { | |||
| 412 | 412 | } | |
| 413 | 413 | for y := range s.buffer.boundaries { | |
| 414 | 414 | s.buffer.boundaries[y] = LineBound{Soft: s.buffer.boundaries[y].Soft} | |
| 415 | + | s.buffer.rowIDs[y] = s.nextRowIDValue() | |
| 415 | 416 | } | |
| 416 | 417 | s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1}) | |
| 417 | 418 | 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 { | |||
| 87 | 87 | } | |
| 88 | 88 | ||
| 89 | 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 | 91 | s.Frame = s.buffer.frame | |
| 92 | 92 | s.Row, s.Col = 0, 0 | |
| 93 | 93 | s.Style = renderer.DefaultStyle() | |
| @@ -203,7 +203,7 @@ func (s *Screen) enterAlternateScreen() { | |||
| 203 | 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 | 207 | s.Frame = s.buffer.frame | |
| 208 | 208 | s.Row, s.Col = 0, 0 | |
| 209 | 209 | s.Style = renderer.DefaultStyle() | |
| @@ -220,6 +220,7 @@ func (s *Screen) exitAlternateScreen() { | |||
| 220 | 220 | s.buffer = state.buffer | |
| 221 | 221 | if s.buffer == nil { | |
| 222 | 222 | s.buffer = bufferFromFrame(cloneFrame(state.frame)) | |
| 223 | + | s.fillMissingRowIDs(s.buffer) | |
| 223 | 224 | } | |
| 224 | 225 | s.Frame = s.buffer.frame | |
| 225 | 226 | 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.frame | 91 | s.Frame = s.buffer.frame | ||
| 92 | s.Row, s.Col = 0, 0 | 92 | 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.frame | 207 | s.Frame = s.buffer.frame | ||
| 208 | s.Row, s.Col = 0, 0 | 208 | 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.buffer | 220 | 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.frame | 225 | 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) { | |||
| 10 | 10 | // renderer.Frame permits a collapsed geometry. There is no layout to | |
| 11 | 11 | // preserve until a usable viewport is supplied again. | |
| 12 | 12 | width, height = max(width, 0), max(height, 0) | |
| 13 | - | s.buffer = newBuffer(width, height) | |
| 13 | + | s.buffer = s.newBuffer(width, height) | |
| 14 | 14 | s.Row, s.Col = 0, 0 | |
| 15 | 15 | if state := s.alternate; state != nil { | |
| 16 | - | state.buffer = newBuffer(width, height) | |
| 16 | + | state.buffer = s.newBuffer(width, height) | |
| 17 | 17 | state.frame = state.buffer.frame | |
| 18 | 18 | state.row, state.col = 0, 0 | |
| 19 | 19 | state.scrollTop, state.scrollBottom = 0, height-1 | |
| @@ -25,10 +25,16 @@ func (s *Screen) Resize(width, height int) { | |||
| 25 | 25 | if saved != nil && saved.saved { | |
| 26 | 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 | 35 | if evict { | |
| 30 | 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 | 40 | if savedPoint != nil { | |
| @@ -44,6 +50,7 @@ func (s *Screen) Resize(width, height int) { | |||
| 44 | 50 | state := s.alternate | |
| 45 | 51 | if state.buffer == nil { | |
| 46 | 52 | state.buffer = bufferFromFrame(state.frame) | |
| 53 | + | s.fillMissingRowIDs(state.buffer) | |
| 47 | 54 | } | |
| 48 | 55 | state.row, state.col = resize(state.buffer, state.row, state.col, &state.savedCursor, true) | |
| 49 | 56 | 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 to | 10 | // 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, 0 | 14 | 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.frame | 17 | state.frame = state.buffer.frame | ||
| 18 | state.row, state.col = 0, 0 | 18 | state.row, state.col = 0, 0 | ||
| 19 | state.scrollTop, state.scrollBottom = 0, height-1 | 19 | 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.alternate | 50 | 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.frame | 56 | 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) { | |||
| 86 | 86 | s.emitLineEvicted(top, n) | |
| 87 | 87 | s.Frame.ScrollUp(top, bottom, n) | |
| 88 | 88 | s.buffer.scrollUp(top, bottom, n) | |
| 89 | + | s.fillMissingRowIDs(s.buffer) | |
| 89 | 90 | s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n}) | |
| 90 | 91 | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1}) | |
| 91 | 92 | return bottom-n >= top | |
| @@ -97,16 +98,16 @@ func (s *Screen) emitLineEvicted(top, n int) { | |||
| 97 | 98 | if s.alternate != nil || top != 0 { | |
| 98 | 99 | return | |
| 99 | 100 | } | |
| 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. | |
| 102 | 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 | 109 | if s.history != nil { | |
| 109 | - | err := s.history.Append(row, bound) | |
| 110 | + | err := s.history.AppendWithID(row, bound, id) | |
| 110 | 111 | if err != nil && !errors.Is(err, ErrHistoryRowTooWide) { | |
| 111 | 112 | panic(err) | |
| 112 | 113 | } | |
| @@ -134,6 +135,7 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) { | |||
| 134 | 135 | // Full-width region: rotate line offsets instead of copying cells. | |
| 135 | 136 | s.Frame.ScrollDown(top, bottom, n) | |
| 136 | 137 | s.buffer.scrollDown(top, bottom, n) | |
| 138 | + | s.fillMissingRowIDs(s.buffer) | |
| 137 | 139 | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1}) | |
| 138 | 140 | } | |
| 139 | 141 | ||
| @@ -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 >= top | 92 | 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 | return | 99 | return | ||
| 99 | } | 100 | } | ||
| 100 | - | // Read boundaries before the caller rotates the frame: a soft link belongs | 101 | + | // 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 { | |||
| 25 | 25 | // terminal viewport. Row returns caller-owned storage; BorrowedRow must not be | |
| 26 | 26 | // mutated and remains valid while the snapshot is retained. | |
| 27 | 27 | 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 | |
| 33 | 35 | } | |
| 34 | 36 | ||
| 35 | 37 | // Snapshot captures the active visible viewport without mutating Screen, | |
| @@ -41,9 +43,15 @@ func (s *Screen) Snapshot() ScreenSnapshot { | |||
| 41 | 43 | } | |
| 42 | 44 | cursorStyle, cursorStyleSet := s.CursorStyle() | |
| 43 | 45 | mouseTracking, mouseSGR := s.MouseMode() | |
| 46 | + | nextRowID := s.nextRowID | |
| 47 | + | if nextRowID < ^RowID(0) { | |
| 48 | + | nextRowID++ | |
| 49 | + | } | |
| 44 | 50 | 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, | |
| 47 | 55 | cursor: CursorSnapshot{ | |
| 48 | 56 | Row: s.CursorRow(), | |
| 49 | 57 | Col: s.CursorCol(), | |
| @@ -84,6 +92,25 @@ func (s ScreenSnapshot) Bound(y int) LineBound { | |||
| 84 | 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 | 114 | func (s ScreenSnapshot) Cursor() CursorSnapshot { return s.cursor } | |
| 88 | 115 | func (s ScreenSnapshot) Modes() ModeSnapshot { return s.modes } | |
| 89 | 116 | 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 be | 25 | // 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.Frame | 28 | + | frame renderer.Frame |
| 29 | - | bounds []LineBound | 29 | + | bounds []LineBound |
| 30 | - | cursor CursorSnapshot | 30 | + | rowIDs []RowID |
| 31 | - | modes ModeSnapshot | 31 | + | cursor CursorSnapshot |
| 32 | - | title string | 32 | + | 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) { | |||
| 202 | 202 | if x1 < s.Frame.Width && s.Frame.At(x1, y).Continuation { | |
| 203 | 203 | x1++ | |
| 204 | 204 | } | |
| 205 | + | fullRow := x0 <= 0 && x1 >= s.Frame.Width | |
| 205 | 206 | blank := s.eraseCell() | |
| 206 | 207 | for x := x0; x < x1; x++ { | |
| 207 | 208 | s.Frame.Set(x, y, blank) | |
| 208 | 209 | } | |
| 209 | 210 | s.buffer.clear(y, x0, x1) | |
| 211 | + | if fullRow { | |
| 212 | + | s.buffer.rowIDs[y] = s.nextRowIDValue() | |
| 213 | + | } | |
| 210 | 214 | return x0, x1 - x0 | |
| 211 | 215 | } | |
| 212 | 216 | ||
| @@ -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 - x0 | 214 | 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) { | |||
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | 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 | 58 | func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) { | |
| 58 | 59 | if view.rows != historyViewRowCount(view) { | |
| 59 | 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 | 68 | sealed[i] = blob | |
| 68 | 69 | } | |
| 69 | - | tail, err := MarshalEmptyHistoryTail() | |
| 70 | + | tail, err := MarshalHistory(HistoryView{nextRowID: view.NextRowID()}) | |
| 70 | 71 | if err != nil { | |
| 71 | 72 | return nil, nil, err | |
| 72 | 73 | } | |
| @@ -76,31 +77,85 @@ func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) { | |||
| 76 | 77 | // HistoryFromBlobs restores history directly from sealed, oldest-first blobs | |
| 77 | 78 | // and the mandatory tail blob. It never feeds decoded rows through Append. | |
| 78 | 79 | 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 { | |
| 81 | 91 | 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) | |
| 84 | 94 | } | |
| 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 | |
| 89 | 96 | } | |
| 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 | + | } | |
| 93 | 106 | } | |
| 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) | |
| 99 | 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 | 133 | h.evict() | |
| 102 | 134 | 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 | |
| 104 | 159 | } | |
| 105 | 160 | ||
| 106 | 161 | // NewScreenWithRecoveryTranscript constructs a fresh blank screen whose | |
| @@ -112,10 +167,11 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se | |||
| 112 | 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 | 171 | if err != nil { | |
| 117 | 172 | return nil, err | |
| 118 | 173 | } | |
| 174 | + | history := restoreHistoryViews(config, sealedViews, tailView) | |
| 119 | 175 | remainingRows := transcriptView.rows | |
| 120 | 176 | for _, chunk := range transcriptView.chunks { | |
| 121 | 177 | for i, row := range chunk.rows { | |
| @@ -124,7 +180,7 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se | |||
| 124 | 180 | if remainingRows == 0 { | |
| 125 | 181 | 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 | 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 | 189 | screen := NewScreenWithHistory(width, height, config) | |
| 134 | 190 | 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 | 200 | return screen, nil | |
| 136 | 201 | } | |
| @@ -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] = blob | 68 | 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, err | 72 | 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 blobs | 77 | // 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].rows | 109 | + | |
| 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, 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 | |||
| 104 | } | 159 | } | ||
| 105 | 160 | ||||
| 106 | // NewScreenWithRecoveryTranscript constructs a fresh blank screen whose | 161 | // 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, err | 172 | return nil, err | ||
| 118 | } | 173 | } | ||
| 174 | + | history := restoreHistoryViews(config, sealedViews, tailView) | |||
| 119 | remainingRows := transcriptView.rows | 175 | 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 = false | 181 | 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 = history | 190 | 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, nil | 200 | return screen, nil | ||
| 136 | } | 201 | } | ||
M pkg/vt/snapshot_test.go +8 -2
| @@ -34,6 +34,11 @@ func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) { | |||
| 34 | 34 | if got, want := view.Cells(), 12; got != want { | |
| 35 | 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 | 43 | tail, err := MarshalHistoryTail(view) | |
| 39 | 44 | require.NoError(t, err) | |
| @@ -44,8 +49,9 @@ func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) { | |||
| 44 | 49 | ||
| 45 | 50 | func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) { | |
| 46 | 51 | 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, | |
| 49 | 55 | }) | |
| 50 | 56 | require.NoError(t, err) | |
| 51 | 57 | ||
| @@ -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 | ||||