detached b1ef94e

fix(vt): finalize stable row identity fixes

commit
b1ef94ed346dd455c99e935ea88b5248c7c07e8b
author
brice · · 2026-08-03 19:23 UTC
parent
14c1427
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +45 -40
M internal/usecase/daemon/status_test.go +9 -2
@@ -634,8 +634,10 @@ func assertSessionDefaultColors(t *testing.T, sess *session, fg, bg renderer.RGB
634634 func assertPaneDefaultColors(t *testing.T, p *pane, fg, bg renderer.RGB) {
635635 t.Helper()
636636 var got []byte
637+ p.mu.Lock()
637638 p.screen.OnResponse = func(b []byte) { got = append(got, b...) }
638639 p.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
640+ p.mu.Unlock()
639641 require.True(t, strings.Contains(string(got), formatOSCColor(fg)), string(got))
640642 require.True(t, strings.Contains(string(got), formatOSCColor(bg)), string(got))
641643 }
@@ -654,17 +656,22 @@ func assertPaneColorScheme(t *testing.T, p *pane, light bool) {
654656 want = "\x1b[?997;2n"
655657 }
656658 var got []byte
659+ p.mu.Lock()
657660 p.screen.OnResponse = func(b []byte) { got = append(got, b...) }
658661 p.screen.Write([]byte("\x1b[?996n"))
662+ p.mu.Unlock()
659663 require.Equal(t, want, string(got))
660664 }
661665
662666 func assertSessionColorSchemeUnknown(t *testing.T, sess *session) {
663667 t.Helper()
664668 for _, tb := range sess.tabs {
669+ p := tb.focusedPane()
665670 var got []byte
666- tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
667- tb.focusedPane().screen.Write([]byte("\x1b[?996n"))
671+ p.mu.Lock()
672+ p.screen.OnResponse = func(b []byte) { got = append(got, b...) }
673+ p.screen.Write([]byte("\x1b[?996n"))
674+ p.mu.Unlock()
668675 require.Empty(t, got)
669676 }
670677 }
@@ -634,8 +634,10 @@ func assertSessionDefaultColors(t *testing.T, sess *session, fg, bg renderer.RGB
634 func assertPaneDefaultColors(t *testing.T, p *pane, fg, bg renderer.RGB) {634 func assertPaneDefaultColors(t *testing.T, p *pane, fg, bg renderer.RGB) {
635 t.Helper()635 t.Helper()
636 var got []byte636 var got []byte
637+ p.mu.Lock()
637 p.screen.OnResponse = func(b []byte) { got = append(got, b...) }638 p.screen.OnResponse = func(b []byte) { got = append(got, b...) }
638 p.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))639 p.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
640+ p.mu.Unlock()
639 require.True(t, strings.Contains(string(got), formatOSCColor(fg)), string(got))641 require.True(t, strings.Contains(string(got), formatOSCColor(fg)), string(got))
640 require.True(t, strings.Contains(string(got), formatOSCColor(bg)), string(got))642 require.True(t, strings.Contains(string(got), formatOSCColor(bg)), string(got))
641 }643 }
@@ -654,17 +656,22 @@ func assertPaneColorScheme(t *testing.T, p *pane, light bool) {
654 want = "\x1b[?997;2n"656 want = "\x1b[?997;2n"
655 }657 }
656 var got []byte658 var got []byte
659+ p.mu.Lock()
657 p.screen.OnResponse = func(b []byte) { got = append(got, b...) }660 p.screen.OnResponse = func(b []byte) { got = append(got, b...) }
658 p.screen.Write([]byte("\x1b[?996n"))661 p.screen.Write([]byte("\x1b[?996n"))
662+ p.mu.Unlock()
659 require.Equal(t, want, string(got))663 require.Equal(t, want, string(got))
660 }664 }
661 665
662 func assertSessionColorSchemeUnknown(t *testing.T, sess *session) {666 func assertSessionColorSchemeUnknown(t *testing.T, sess *session) {
663 t.Helper()667 t.Helper()
664 for _, tb := range sess.tabs {668 for _, tb := range sess.tabs {
669+ p := tb.focusedPane()
665 var got []byte670 var got []byte
666- tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }671+ p.mu.Lock()
667- tb.focusedPane().screen.Write([]byte("\x1b[?996n"))672+ p.screen.OnResponse = func(b []byte) { got = append(got, b...) }
673+ p.screen.Write([]byte("\x1b[?996n"))
674+ p.mu.Unlock()
668 require.Empty(t, got)675 require.Empty(t, got)
669 }676 }
670 }677 }
M internal/usecase/snapshot/codec_test.go +2 -2
@@ -224,8 +224,8 @@ func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) {
224224
225225 func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) {
226226 sealed, emptyTail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}})
227- if len(sealed) == 0 {
228- t.Fatal("expected a sealed chunk")
227+ if len(sealed) < 2 {
228+ t.Fatalf("expected at least two sealed chunks, got %d", len(sealed))
229229 }
230230 multiChunk := append([]byte(nil), sealed[0][:17]...)
231231 binary.BigEndian.PutUint32(multiChunk[5:9], 2)
@@ -224,8 +224,8 @@ 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][:17]...)230 multiChunk := append([]byte(nil), sealed[0][:17]...)
231 binary.BigEndian.PutUint32(multiChunk[5:9], 2)231 binary.BigEndian.PutUint32(multiChunk[5:9], 2)
M pkg/vt/history_codec.go +11 -9
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "fmt"
77 "math"
8+ "slices"
89 "unicode/utf8"
910
1011 "github.com/bnema/vev/pkg/renderer"
@@ -133,13 +134,13 @@ func historyViewNextRowID(view HistoryView) (RowID, bool) {
133134 }
134135
135136 func maxHistoryRowID(ids map[RowID]struct{}) RowID {
136- var max RowID
137+ var maxID RowID
137138 for id := range ids {
138- if id > max {
139- max = id
139+ if id > maxID {
140+ maxID = id
140141 }
141142 }
142- return max
143+ return maxID
143144 }
144145
145146 func historyViewRowCount(view HistoryView) int {
@@ -289,11 +290,6 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
289290 return HistoryView{}, historyDecodeStats{}, false
290291 }
291292 rowID := RowID(id)
292- for i := 0; i < seenCount; i++ {
293- if seenIDs[i] == rowID {
294- return HistoryView{}, historyDecodeStats{}, false
295- }
296- }
297293 seenIDs[seenCount] = rowID
298294 seenCount++
299295 if rowID > maxID {
@@ -336,6 +332,12 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
336332 chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds, rowIDs: rowIDs})
337333 }
338334 }
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+ }
339341 if len(p.data) != 0 || RowID(nextRowID) <= maxID {
340342 return HistoryView{}, historyDecodeStats{}, false
341343 }
@@ -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"
@@ -133,13 +134,13 @@ func historyViewNextRowID(view HistoryView) (RowID, bool) {
133 }134 }
134 135
135 func maxHistoryRowID(ids map[RowID]struct{}) RowID {136 func maxHistoryRowID(ids map[RowID]struct{}) RowID {
136- var max RowID137+ var maxID RowID
137 for id := range ids {138 for id := range ids {
138- if id > max {139+ if id > maxID {
139- max = id140+ maxID = id
140 }141 }
141 }142 }
142- return max143+ return maxID
143 }144 }
144 145
145 func historyViewRowCount(view HistoryView) int {146 func historyViewRowCount(view HistoryView) int {
@@ -289,11 +290,6 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
289 return HistoryView{}, historyDecodeStats{}, false290 return HistoryView{}, historyDecodeStats{}, false
290 }291 }
291 rowID := RowID(id)292 rowID := RowID(id)
292- for i := 0; i < seenCount; i++ {
293- if seenIDs[i] == rowID {
294- return HistoryView{}, historyDecodeStats{}, false
295- }
296- }
297 seenIDs[seenCount] = rowID293 seenIDs[seenCount] = rowID
298 seenCount++294 seenCount++
299 if rowID > maxID {295 if rowID > maxID {
@@ -336,6 +332,12 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
336 chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds, rowIDs: rowIDs})332 chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds, rowIDs: rowIDs})
337 }333 }
338 }334 }
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+ }
339 if len(p.data) != 0 || RowID(nextRowID) <= maxID {341 if len(p.data) != 0 || RowID(nextRowID) <= maxID {
340 return HistoryView{}, historyDecodeStats{}, false342 return HistoryView{}, historyDecodeStats{}, false
341 }343 }
M pkg/vt/history_codec_test.go +8 -25
@@ -67,7 +67,7 @@ func TestHistoryCodecRoundTripsBounds(t *testing.T) {
6767 }
6868 }
6969
70-func TestHistoryCodecRejectsMalformedAndLegacyPayloads(t *testing.T) {
70+func TestHistoryCodecRejectsLegacyPayloads(t *testing.T) {
7171 // The previous layouts are deliberately incompatible with stable IDs.
7272 legacy := []byte{
7373 'V', 'T', 'H', '1', 2,
@@ -88,15 +88,6 @@ func TestHistoryCodecRejectsMalformedAndLegacyPayloads(t *testing.T) {
8888 t.Fatalf("accepted legacy history version %d", version)
8989 }
9090 }
91-
92- for i := 0; i < len(legacy); i++ {
93- if _, err := UnmarshalHistory(legacy[:i]); err == nil {
94- t.Fatalf("accepted truncated prefix of length %d", i)
95- }
96- }
97- if _, err := UnmarshalHistory(append(append([]byte(nil), legacy...), 0xff)); err == nil {
98- t.Fatal("accepted trailing garbage")
99- }
10091 }
10192
10293 func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) {
@@ -172,21 +163,13 @@ func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) {
172163 t.Fatalf("marshal history: %v", err)
173164 }
174165
175- tests := []struct {
176- name string
177- data []byte
178- }{
179- {name: "empty", data: nil},
180- {name: "truncated prefix", data: encoded[:len(encoded)-1]},
181- {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+ }
182170 }
183-
184- for _, tt := range tests {
185- t.Run(tt.name, func(t *testing.T) {
186- if _, err := UnmarshalHistory(tt.data); err == nil {
187- t.Fatal("unmarshal accepted malformed history payload")
188- }
189- })
171+ if _, err := UnmarshalHistory(append(append([]byte(nil), encoded...), 0)); err == nil {
172+ t.Fatal("accepted trailing garbage after version 3 payload")
190173 }
191174 }
192175
@@ -335,7 +318,7 @@ func historyPayloadWithDimensions(rowCount, width int) []byte {
335318 //nolint:makezero // The header prefix is retained while zeroed cell records are appended.
336319 data = append(data, make([]byte, width*historyCellBytes)...)
337320 data = binary.BigEndian.AppendUint32(data, uint32(width))
338- data = append(data, 0)
321+ data = append(data, 0) //nolint:makezero // The header prefix is retained while the bound is appended.
339322 }
340323 rowCount -= chunkRows
341324 }
@@ -67,7 +67,7 @@ func TestHistoryCodecRoundTripsBounds(t *testing.T) {
67 }67 }
68 }68 }
69 69
70-func TestHistoryCodecRejectsMalformedAndLegacyPayloads(t *testing.T) {70+func TestHistoryCodecRejectsLegacyPayloads(t *testing.T) {
71 // The previous layouts are deliberately incompatible with stable IDs.71 // The previous layouts are deliberately incompatible with stable IDs.
72 legacy := []byte{72 legacy := []byte{
73 'V', 'T', 'H', '1', 2,73 'V', 'T', 'H', '1', 2,
@@ -88,15 +88,6 @@ func TestHistoryCodecRejectsMalformedAndLegacyPayloads(t *testing.T) {
88 t.Fatalf("accepted legacy history version %d", version)88 t.Fatalf("accepted legacy history version %d", version)
89 }89 }
90 }90 }
91-
92- for i := 0; i < len(legacy); i++ {
93- if _, err := UnmarshalHistory(legacy[:i]); err == nil {
94- t.Fatalf("accepted truncated prefix of length %d", i)
95- }
96- }
97- if _, err := UnmarshalHistory(append(append([]byte(nil), legacy...), 0xff)); err == nil {
98- t.Fatal("accepted trailing garbage")
99- }
100 }91 }
101 92
102 func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) {93 func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) {
@@ -172,21 +163,13 @@ func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) {
172 t.Fatalf("marshal history: %v", err)163 t.Fatalf("marshal history: %v", err)
173 }164 }
174 165
175- tests := []struct {166+ for i := range len(encoded) {
176- name string167+ if _, err := UnmarshalHistory(encoded[:i]); err == nil {
177- data []byte168+ t.Fatalf("accepted truncated version 3 prefix of length %d", i)
178- }{169+ }
179- {name: "empty", data: nil},
180- {name: "truncated prefix", data: encoded[:len(encoded)-1]},
181- {name: "trailing byte", data: append(append([]byte(nil), encoded...), 0)},
182 }170 }
183-171+ if _, err := UnmarshalHistory(append(append([]byte(nil), encoded...), 0)); err == nil {
184- for _, tt := range tests {172+ t.Fatal("accepted trailing garbage after version 3 payload")
185- t.Run(tt.name, func(t *testing.T) {
186- if _, err := UnmarshalHistory(tt.data); err == nil {
187- t.Fatal("unmarshal accepted malformed history payload")
188- }
189- })
190 }173 }
191 }174 }
192 175
@@ -335,7 +318,7 @@ func historyPayloadWithDimensions(rowCount, width int) []byte {
335 //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.
336 data = append(data, make([]byte, width*historyCellBytes)...)319 data = append(data, make([]byte, width*historyCellBytes)...)
337 data = binary.BigEndian.AppendUint32(data, uint32(width))320 data = binary.BigEndian.AppendUint32(data, uint32(width))
338- data = append(data, 0)321+ data = append(data, 0) //nolint:makezero // The header prefix is retained while the bound is appended.
339 }322 }
340 rowCount -= chunkRows323 rowCount -= chunkRows
341 }324 }
M pkg/vt/row_id_test.go +12 -0
@@ -49,6 +49,18 @@ func TestScreenRowIDsDoNotAppendInteriorOrAlternateScrollHistory(t *testing.T) {
4949 }
5050 }
5151
52+func TestScreenRowIDRefreshesOnSnapExpandedFullClear(t *testing.T) {
53+ s := NewScreen(2, 1)
54+ s.Write([]byte("界"))
55+ before := s.RowID(0)
56+
57+ s.clearRow(0, 1, 2)
58+
59+ if got := s.RowID(0); got == before {
60+ t.Fatalf("row ID = %d after snap-expanded full clear, want a fresh ID", got)
61+ }
62+}
63+
5264 func TestScreenRowIDsRefreshOnClearResetAndAlternateClone(t *testing.T) {
5365 s := NewScreen(4, 3)
5466 before := s.RowIDs()
@@ -49,6 +49,18 @@ func TestScreenRowIDsDoNotAppendInteriorOrAlternateScrollHistory(t *testing.T) {
49 }49 }
50 }50 }
51 51
52+func TestScreenRowIDRefreshesOnSnapExpandedFullClear(t *testing.T) {
53+ s := NewScreen(2, 1)
54+ s.Write([]byte("界"))
55+ before := s.RowID(0)
56+
57+ s.clearRow(0, 1, 2)
58+
59+ if got := s.RowID(0); got == before {
60+ t.Fatalf("row ID = %d after snap-expanded full clear, want a fresh ID", got)
61+ }
62+}
63+
52 func TestScreenRowIDsRefreshOnClearResetAndAlternateClone(t *testing.T) {64 func TestScreenRowIDsRefreshOnClearResetAndAlternateClone(t *testing.T) {
53 s := NewScreen(4, 3)65 s := NewScreen(4, 3)
54 before := s.RowIDs()66 before := s.RowIDs()
M pkg/vt/screen_write.go +1 -1
@@ -184,7 +184,6 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
184184 if y < 0 || y >= s.Frame.Height {
185185 return x0, 0
186186 }
187- fullRow := x0 <= 0 && x1 >= s.Frame.Width
188187 if x0 < 0 {
189188 x0 = 0
190189 }
@@ -203,6 +202,7 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
203202 if x1 < s.Frame.Width && s.Frame.At(x1, y).Continuation {
204203 x1++
205204 }
205+ fullRow := x0 <= 0 && x1 >= s.Frame.Width
206206 blank := s.eraseCell()
207207 for x := x0; x < x1; x++ {
208208 s.Frame.Set(x, y, blank)
@@ -184,7 +184,6 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
184 if y < 0 || y >= s.Frame.Height {184 if y < 0 || y >= s.Frame.Height {
185 return x0, 0185 return x0, 0
186 }186 }
187- fullRow := x0 <= 0 && x1 >= s.Frame.Width
188 if x0 < 0 {187 if x0 < 0 {
189 x0 = 0188 x0 = 0
190 }189 }
@@ -203,6 +202,7 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
203 if x1 < s.Frame.Width && s.Frame.At(x1, y).Continuation {202 if x1 < s.Frame.Width && s.Frame.At(x1, y).Continuation {
204 x1++203 x1++
205 }204 }
205+ fullRow := x0 <= 0 && x1 >= s.Frame.Width
206 blank := s.eraseCell()206 blank := s.eraseCell()
207 for x := x0; x < x1; x++ {207 for x := x0; x < x1; x++ {
208 s.Frame.Set(x, y, blank)208 s.Frame.Set(x, y, blank)
M pkg/vt/snapshot.go +2 -1
@@ -53,7 +53,8 @@ func MarshalHistoryTail(view HistorySnapshotView) ([]byte, error) {
5353 }
5454
5555 // MarshalSealedHistory serializes a SealAndView result as oldest-first,
56-// self-contained sealed blobs plus a mandatory empty canonical tail blob.
56+// self-contained sealed blobs plus a mandatory empty tail blob. The empty tail
57+// carries NextRowID and is not the canonical default empty encoding.
5758 func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) {
5859 if view.rows != historyViewRowCount(view) {
5960 return nil, nil, fmt.Errorf("marshal sealed history: %w", errInvalidHistory)
@@ -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)

Keyboard shortcuts

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

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

Branches 2

Tags 13

↑↓ moveenter openesc close