detached ff84b77

feat(vt): persist per-row logical extents, bump history format to v2

commit
ff84b777fbf5f6f0546ba460d494e6a735f724e9
author
brice · · 2026-07-25 06:06 UTC
parent
74eecd7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +138 -4
M pkg/vt/history_codec.go +49 -4
@@ -12,7 +12,7 @@ import (
1212
1313 const (
1414 historyMagic = "VTH1"
15- historyVersion = 1
15+ historyVersion = 2
1616 historyCellBytes = 41
1717 maxHistoryChunkRows = 256
1818
@@ -64,10 +64,14 @@ func MarshalHistory(view HistoryView) ([]byte, error) {
6464 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
6565 }
6666 out = appendUint32(out, uint32(len(chunk.rows)))
67- for _, row := range chunk.rows {
67+ for i, row := range chunk.rows {
6868 if uint64(len(row)) > math.MaxUint32 {
6969 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
7070 }
71+ bound := chunkBound(chunk, i)
72+ if !validHistoryBound(bound, len(row)) {
73+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
74+ }
7175 out = appendUint32(out, uint32(len(row)))
7276 for _, cell := range row {
7377 if !validHistoryCell(cell) {
@@ -75,6 +79,7 @@ func MarshalHistory(view HistoryView) ([]byte, error) {
7579 }
7680 out = appendHistoryCell(out, cell)
7781 }
82+ out = appendHistoryBound(out, bound)
7883 }
7984 }
8085 return out, nil
@@ -135,6 +140,26 @@ func appendHistoryCell(dst []byte, cell renderer.Cell) []byte {
135140 return append(dst, style.UnderlineColorRGB.R, style.UnderlineColorRGB.G, style.UnderlineColorRGB.B)
136141 }
137142
143+func chunkBound(chunk *HistoryChunk, i int) LineBound {
144+ if i < 0 || i >= len(chunk.bounds) {
145+ return LineBound{}
146+ }
147+ return chunk.bounds[i]
148+}
149+
150+func appendHistoryBound(dst []byte, b LineBound) []byte {
151+ dst = binary.BigEndian.AppendUint32(dst, uint32(b.End))
152+ if b.Soft {
153+ return append(dst, 1)
154+ }
155+ return append(dst, 0)
156+}
157+
158+// validHistoryBound rejects extents that cannot describe their own row.
159+func validHistoryBound(b LineBound, cells int) bool {
160+ return b.End >= 0 && b.End <= cells
161+}
162+
138163 // UnmarshalHistory strictly decodes a MarshalHistory payload. It rejects
139164 // malformed declarations, cells, truncated data, and trailing bytes.
140165 func UnmarshalHistory(data []byte) (HistoryView, error) {
@@ -181,14 +206,16 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
181206 for range chunkCount {
182207 rowCount, ok := p.uint32()
183208 if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows ||
184- uint64(rowCount) > uint64(len(p.data))/4 ||
209+ uint64(rowCount) > uint64(len(p.data))/(4+visibleBoundaryBytes) ||
185210 !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) {
186211 return HistoryView{}, historyDecodeStats{}, false
187212 }
188213
189214 var rows [][]renderer.Cell
215+ var bounds []LineBound
190216 if populate {
191217 rows = make([][]renderer.Cell, 0, rowCount)
218+ bounds = make([]LineBound, 0, rowCount)
192219 }
193220 for range rowCount {
194221 cellCount, ok := p.uint32()
@@ -214,12 +241,17 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
214241 row[i] = cell
215242 }
216243 }
244+ bound, ok := p.bound()
245+ if !ok || !validHistoryBound(bound, int(cellCount)) {
246+ return HistoryView{}, historyDecodeStats{}, false
247+ }
217248 if populate {
218249 rows = append(rows, row)
250+ bounds = append(bounds, bound)
219251 }
220252 }
221253 if populate {
222- chunks = append(chunks, &HistoryChunk{rows: rows})
254+ chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds})
223255 }
224256 }
225257 if len(p.data) != 0 {
@@ -296,6 +328,19 @@ func (p *historyParser) cell() (renderer.Cell, bool) {
296328 }, true
297329 }
298330
331+func (p *historyParser) bound() (LineBound, bool) {
332+ if len(p.data) < visibleBoundaryBytes {
333+ return LineBound{}, false
334+ }
335+ end := binary.BigEndian.Uint32(p.data[:4])
336+ flag := p.data[4]
337+ p.data = p.data[visibleBoundaryBytes:]
338+ if flag > 1 || uint64(end) > math.MaxInt32 {
339+ return LineBound{}, false
340+ }
341+ return LineBound{End: int(end), Soft: flag == 1}, true
342+}
343+
299344 func historyInt(raw uint64) (int, bool) {
300345 v := int64(raw)
301346 if int64(int(v)) != v {
@@ -12,7 +12,7 @@ import (
12 12
13 const (13 const (
14 historyMagic = "VTH1"14 historyMagic = "VTH1"
15- historyVersion = 115+ historyVersion = 2
16 historyCellBytes = 4116 historyCellBytes = 41
17 maxHistoryChunkRows = 25617 maxHistoryChunkRows = 256
18 18
@@ -64,10 +64,14 @@ func MarshalHistory(view HistoryView) ([]byte, error) {
64 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)64 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
65 }65 }
66 out = appendUint32(out, uint32(len(chunk.rows)))66 out = appendUint32(out, uint32(len(chunk.rows)))
67- for _, row := range chunk.rows {67+ for i, row := range chunk.rows {
68 if uint64(len(row)) > math.MaxUint32 {68 if uint64(len(row)) > math.MaxUint32 {
69 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)69 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
70 }70 }
71+ bound := chunkBound(chunk, i)
72+ if !validHistoryBound(bound, len(row)) {
73+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
74+ }
71 out = appendUint32(out, uint32(len(row)))75 out = appendUint32(out, uint32(len(row)))
72 for _, cell := range row {76 for _, cell := range row {
73 if !validHistoryCell(cell) {77 if !validHistoryCell(cell) {
@@ -75,6 +79,7 @@ func MarshalHistory(view HistoryView) ([]byte, error) {
75 }79 }
76 out = appendHistoryCell(out, cell)80 out = appendHistoryCell(out, cell)
77 }81 }
82+ out = appendHistoryBound(out, bound)
78 }83 }
79 }84 }
80 return out, nil85 return out, nil
@@ -135,6 +140,26 @@ func appendHistoryCell(dst []byte, cell renderer.Cell) []byte {
135 return append(dst, style.UnderlineColorRGB.R, style.UnderlineColorRGB.G, style.UnderlineColorRGB.B)140 return append(dst, style.UnderlineColorRGB.R, style.UnderlineColorRGB.G, style.UnderlineColorRGB.B)
136 }141 }
137 142
143+func chunkBound(chunk *HistoryChunk, i int) LineBound {
144+ if i < 0 || i >= len(chunk.bounds) {
145+ return LineBound{}
146+ }
147+ return chunk.bounds[i]
148+}
149+
150+func appendHistoryBound(dst []byte, b LineBound) []byte {
151+ dst = binary.BigEndian.AppendUint32(dst, uint32(b.End))
152+ if b.Soft {
153+ return append(dst, 1)
154+ }
155+ return append(dst, 0)
156+}
157+
158+// validHistoryBound rejects extents that cannot describe their own row.
159+func validHistoryBound(b LineBound, cells int) bool {
160+ return b.End >= 0 && b.End <= cells
161+}
162+
138 // UnmarshalHistory strictly decodes a MarshalHistory payload. It rejects163 // UnmarshalHistory strictly decodes a MarshalHistory payload. It rejects
139 // malformed declarations, cells, truncated data, and trailing bytes.164 // malformed declarations, cells, truncated data, and trailing bytes.
140 func UnmarshalHistory(data []byte) (HistoryView, error) {165 func UnmarshalHistory(data []byte) (HistoryView, error) {
@@ -181,14 +206,16 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
181 for range chunkCount {206 for range chunkCount {
182 rowCount, ok := p.uint32()207 rowCount, ok := p.uint32()
183 if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows ||208 if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows ||
184- uint64(rowCount) > uint64(len(p.data))/4 ||209+ uint64(rowCount) > uint64(len(p.data))/(4+visibleBoundaryBytes) ||
185 !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) {210 !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) {
186 return HistoryView{}, historyDecodeStats{}, false211 return HistoryView{}, historyDecodeStats{}, false
187 }212 }
188 213
189 var rows [][]renderer.Cell214 var rows [][]renderer.Cell
215+ var bounds []LineBound
190 if populate {216 if populate {
191 rows = make([][]renderer.Cell, 0, rowCount)217 rows = make([][]renderer.Cell, 0, rowCount)
218+ bounds = make([]LineBound, 0, rowCount)
192 }219 }
193 for range rowCount {220 for range rowCount {
194 cellCount, ok := p.uint32()221 cellCount, ok := p.uint32()
@@ -214,12 +241,17 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
214 row[i] = cell241 row[i] = cell
215 }242 }
216 }243 }
244+ bound, ok := p.bound()
245+ if !ok || !validHistoryBound(bound, int(cellCount)) {
246+ return HistoryView{}, historyDecodeStats{}, false
247+ }
217 if populate {248 if populate {
218 rows = append(rows, row)249 rows = append(rows, row)
250+ bounds = append(bounds, bound)
219 }251 }
220 }252 }
221 if populate {253 if populate {
222- chunks = append(chunks, &HistoryChunk{rows: rows})254+ chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds})
223 }255 }
224 }256 }
225 if len(p.data) != 0 {257 if len(p.data) != 0 {
@@ -296,6 +328,19 @@ func (p *historyParser) cell() (renderer.Cell, bool) {
296 }, true328 }, true
297 }329 }
298 330
331+func (p *historyParser) bound() (LineBound, bool) {
332+ if len(p.data) < visibleBoundaryBytes {
333+ return LineBound{}, false
334+ }
335+ end := binary.BigEndian.Uint32(p.data[:4])
336+ flag := p.data[4]
337+ p.data = p.data[visibleBoundaryBytes:]
338+ if flag > 1 || uint64(end) > math.MaxInt32 {
339+ return LineBound{}, false
340+ }
341+ return LineBound{End: int(end), Soft: flag == 1}, true
342+}
343+
299 func historyInt(raw uint64) (int, bool) {344 func historyInt(raw uint64) (int, bool) {
300 v := int64(raw)345 v := int64(raw)
301 if int64(int(v)) != v {346 if int64(int(v)) != v {
M pkg/vt/history_codec_test.go +89 -0
@@ -1,6 +1,7 @@
11 package vt
22
33 import (
4+ "bytes"
45 "encoding/binary"
56 "fmt"
67 "math"
@@ -9,6 +10,94 @@ import (
910 "github.com/bnema/vev/pkg/renderer"
1011 )
1112
13+func TestHistoryCodecV2Bytes(t *testing.T) {
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 {
16+ t.Fatalf("append: %v", err)
17+ }
18+ got, err := MarshalHistory(h.SealAndView())
19+ if err != nil {
20+ t.Fatalf("marshal: %v", err)
21+ }
22+
23+ want := []byte{
24+ 'V', 'T', 'H', '1', 2, // magic, version
25+ 0, 0, 0, 1, // chunk count
26+ 0, 0, 0, 1, // row count
27+ 0, 0, 0, 1, // cell count
28+ 0, 0, 0, 'a', // Rune
29+ 0, // flags
30+ 0, 0, // Attrs
31+ 0, 0, 0, 0, 0, 0, 0, 0, // Foreground
32+ 0, 0, 0, 0, 0, 0, 0, 0, // Background
33+ 0, 0, 0, // ForegroundRGB
34+ 0, 0, 0, // BackgroundRGB
35+ 0, // UnderlineStyle
36+ 0, 0, 0, 0, 0, 0, 0, 0, // UnderlineColor
37+ 0, 0, 0, // UnderlineColorRGB — 41 cell bytes through here
38+ 0, 0, 0, 1, 1, // bound: End=1, Soft=true
39+ }
40+ if !bytes.Equal(got, want) {
41+ t.Fatalf("MarshalHistory() = % x, want % x", got, want)
42+ }
43+}
44+
45+func TestHistoryCodecRoundTripsBounds(t *testing.T) {
46+ h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2})
47+ bounds := []LineBound{{End: 2, Soft: true}, {End: 1}, {End: 2, Soft: true}}
48+ for _, b := range bounds {
49+ if err := h.Append([]renderer.Cell{{Rune: 'a'}, {Rune: 'b'}}, b); err != nil {
50+ t.Fatalf("append: %v", err)
51+ }
52+ }
53+ blob, err := MarshalHistory(h.SealAndView())
54+ if err != nil {
55+ t.Fatalf("marshal: %v", err)
56+ }
57+ view, err := UnmarshalHistory(blob)
58+ if err != nil {
59+ t.Fatalf("unmarshal: %v", err)
60+ }
61+ for i, want := range bounds {
62+ if got := view.Bound(i); got != want {
63+ t.Errorf("Bound(%d) = %+v, want %+v", i, got, want)
64+ }
65+ }
66+}
67+
68+func TestHistoryCodecRejectsMalformedAndV1Payloads(t *testing.T) {
69+ // Canonical v2 fixture from TestHistoryCodecV2Bytes.
70+ v2 := []byte{
71+ '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,
75+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
76+ 0, 0, 0, 0, 0, 0,
77+ 0,
78+ 0, 0, 0, 0, 0, 0, 0, 0,
79+ 0, 0, 0,
80+ 0, 0, 0, 1, 1,
81+ }
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)-visibleBoundaryBytes]...)
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)
94+ }
95+ }
96+ if _, err := UnmarshalHistory(append(append([]byte(nil), v2...), 0xff)); err == nil {
97+ t.Fatal("accepted trailing garbage")
98+ }
99+}
100+
12101 func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) {
13102 indexed := renderer.Style{
14103 Bold: true,
@@ -1,6 +1,7 @@
1 package vt1 package vt
2 2
3 import (3 import (
4+ "bytes"
4 "encoding/binary"5 "encoding/binary"
5 "fmt"6 "fmt"
6 "math"7 "math"
@@ -9,6 +10,94 @@ import (
9 "github.com/bnema/vev/pkg/renderer"10 "github.com/bnema/vev/pkg/renderer"
10 )11 )
11 12
13+func TestHistoryCodecV2Bytes(t *testing.T) {
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 {
16+ t.Fatalf("append: %v", err)
17+ }
18+ got, err := MarshalHistory(h.SealAndView())
19+ if err != nil {
20+ t.Fatalf("marshal: %v", err)
21+ }
22+
23+ want := []byte{
24+ 'V', 'T', 'H', '1', 2, // magic, version
25+ 0, 0, 0, 1, // chunk count
26+ 0, 0, 0, 1, // row count
27+ 0, 0, 0, 1, // cell count
28+ 0, 0, 0, 'a', // Rune
29+ 0, // flags
30+ 0, 0, // Attrs
31+ 0, 0, 0, 0, 0, 0, 0, 0, // Foreground
32+ 0, 0, 0, 0, 0, 0, 0, 0, // Background
33+ 0, 0, 0, // ForegroundRGB
34+ 0, 0, 0, // BackgroundRGB
35+ 0, // UnderlineStyle
36+ 0, 0, 0, 0, 0, 0, 0, 0, // UnderlineColor
37+ 0, 0, 0, // UnderlineColorRGB — 41 cell bytes through here
38+ 0, 0, 0, 1, 1, // bound: End=1, Soft=true
39+ }
40+ if !bytes.Equal(got, want) {
41+ t.Fatalf("MarshalHistory() = % x, want % x", got, want)
42+ }
43+}
44+
45+func TestHistoryCodecRoundTripsBounds(t *testing.T) {
46+ h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2})
47+ bounds := []LineBound{{End: 2, Soft: true}, {End: 1}, {End: 2, Soft: true}}
48+ for _, b := range bounds {
49+ if err := h.Append([]renderer.Cell{{Rune: 'a'}, {Rune: 'b'}}, b); err != nil {
50+ t.Fatalf("append: %v", err)
51+ }
52+ }
53+ blob, err := MarshalHistory(h.SealAndView())
54+ if err != nil {
55+ t.Fatalf("marshal: %v", err)
56+ }
57+ view, err := UnmarshalHistory(blob)
58+ if err != nil {
59+ t.Fatalf("unmarshal: %v", err)
60+ }
61+ for i, want := range bounds {
62+ if got := view.Bound(i); got != want {
63+ t.Errorf("Bound(%d) = %+v, want %+v", i, got, want)
64+ }
65+ }
66+}
67+
68+func TestHistoryCodecRejectsMalformedAndV1Payloads(t *testing.T) {
69+ // Canonical v2 fixture from TestHistoryCodecV2Bytes.
70+ v2 := []byte{
71+ '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,
75+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
76+ 0, 0, 0, 0, 0, 0,
77+ 0,
78+ 0, 0, 0, 0, 0, 0, 0, 0,
79+ 0, 0, 0,
80+ 0, 0, 0, 1, 1,
81+ }
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)-visibleBoundaryBytes]...)
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)
94+ }
95+ }
96+ if _, err := UnmarshalHistory(append(append([]byte(nil), v2...), 0xff)); err == nil {
97+ t.Fatal("accepted trailing garbage")
98+ }
99+}
100+
12 func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) {101 func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) {
13 indexed := renderer.Style{102 indexed := renderer.Style{
14 Bold: true,103 Bold: true,

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close