detached d75df92

feat(vt): bound history by retained cells

commit
d75df92e39b2c94e0d24362cae26e6dbb192467e
author
brice · · 2026-07-21 18:45 UTC
parent
2b23600
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +216 -19
M pkg/vt/history.go +141 -16
@@ -1,10 +1,20 @@
11 package vt
22
3-import "github.com/bnema/vev/pkg/renderer"
3+import (
4+ "errors"
5+ "math"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+// ErrHistoryRowTooWide is returned when a row cannot fit within the configured
11+// cell budget. The history is not modified when this error is returned.
12+var ErrHistoryRowTooWide = errors.New("history row exceeds cell capacity")
413
514 // HistoryConfig controls the bounded terminal history retained by a Screen.
615 type HistoryConfig struct {
716 MaxRows int
17+ MaxCells int
818 ChunkRows int
919 }
1020
@@ -13,10 +23,12 @@ type HistoryConfig struct {
1323 // appends.
1424 type History struct {
1525 maxRows int
26+ maxCells int
1627 chunkRows int
1728 chunks []*HistoryChunk
1829 tail [][]renderer.Cell
1930 rows int
31+ cells int
2032 }
2133
2234 // HistoryChunk is an immutable group of history rows. Its identity is stable
@@ -30,6 +42,17 @@ type HistoryChunk struct {
3042 type HistoryView struct {
3143 chunks []*HistoryChunk
3244 rows int
45+ cells int
46+}
47+
48+// HistorySnapshotView captures sealed history chunks and the mutable tail
49+// independently. Sealed chunks are shared by identity; Tail is owned by the
50+// view and can be serialized without rotating the live tail into a chunk.
51+type HistorySnapshotView struct {
52+ chunks []*HistoryChunk
53+ tail [][]renderer.Cell
54+ rows int
55+ cells int
3356 }
3457
3558 func NewHistory(config HistoryConfig) *History {
@@ -42,20 +65,37 @@ func NewHistory(config HistoryConfig) *History {
4265 }
4366 chunkRows = min(chunkRows, 256)
4467 chunkRows = min(chunkRows, config.MaxRows)
45- return &History{maxRows: config.MaxRows, chunkRows: chunkRows}
68+ maxCells := config.MaxCells
69+ if maxCells <= 0 {
70+ maxCells = defaultHistoryMaxCells(config.MaxRows)
71+ }
72+ return &History{maxRows: config.MaxRows, maxCells: maxCells, chunkRows: chunkRows}
73+}
74+
75+func defaultHistoryMaxCells(maxRows int) int {
76+ if maxRows > math.MaxInt/160 {
77+ return math.MaxInt
78+ }
79+ return maxRows * 160
4680 }
4781
4882 // Append records a copy of row. Once a chunk is full it is sealed forever.
49-func (h *History) Append(row []renderer.Cell) {
83+// Rows wider than the total cell capacity are rejected without mutation.
84+func (h *History) Append(row []renderer.Cell) error {
5085 if h == nil || h.maxRows == 0 {
51- return
86+ return nil
87+ }
88+ if len(row) > h.maxCells {
89+ return ErrHistoryRowTooWide
5290 }
5391 h.tail = append(h.tail, append([]renderer.Cell(nil), row...))
5492 h.rows++
93+ h.cells += len(row)
5594 if len(h.tail) == h.chunkRows {
5695 h.sealTail()
5796 }
5897 h.evict()
98+ return nil
5999 }
60100
61101 func (h *History) sealTail() {
@@ -67,12 +107,31 @@ func (h *History) sealTail() {
67107 }
68108
69109 func (h *History) evict() {
70- for h.rows > h.maxRows && len(h.chunks) > 0 {
71- evicted := h.chunks[0]
72- h.rows -= len(evicted.rows)
73- copy(h.chunks, h.chunks[1:])
74- h.chunks[len(h.chunks)-1] = nil
75- h.chunks = h.chunks[:len(h.chunks)-1]
110+ for h.rows > h.maxRows || h.cells > h.maxCells {
111+ if len(h.chunks) > 0 {
112+ chunk := h.chunks[0]
113+ row := chunk.rows[0]
114+ h.rows--
115+ h.cells -= len(row)
116+ if len(chunk.rows) == 1 {
117+ copy(h.chunks, h.chunks[1:])
118+ h.chunks[len(h.chunks)-1] = nil
119+ h.chunks = h.chunks[:len(h.chunks)-1]
120+ } else {
121+ // Preserve cell storage while replacing only the chunk wrapper: a
122+ // retained view may still refer to the original immutable chunk.
123+ h.chunks[0] = &HistoryChunk{rows: chunk.rows[1:]}
124+ }
125+ continue
126+ }
127+ if len(h.tail) == 0 {
128+ return
129+ }
130+ row := h.tail[0]
131+ h.rows--
132+ h.cells -= len(row)
133+ h.tail[0] = nil
134+ h.tail = h.tail[1:]
76135 }
77136 }
78137
@@ -93,13 +152,34 @@ func (h *History) View() HistoryView {
93152 }
94153 chunks := append([]*HistoryChunk(nil), h.chunks...)
95154 if len(h.tail) > 0 {
96- rows := make([][]renderer.Cell, len(h.tail))
97- for i, row := range h.tail {
98- rows[i] = append([]renderer.Cell(nil), row...)
99- }
100- chunks = append(chunks, &HistoryChunk{rows: rows})
155+ chunks = append(chunks, &HistoryChunk{rows: cloneHistoryRows(h.tail)})
156+ }
157+ return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells}
158+}
159+
160+// SnapshotView captures history for persistence without sealing the mutable
161+// tail. Sealed chunks are shared by identity and the tail is deeply copied.
162+func (h *History) SnapshotView() HistorySnapshotView {
163+ if h == nil || h.rows == 0 {
164+ return HistorySnapshotView{}
165+ }
166+ return HistorySnapshotView{
167+ chunks: append([]*HistoryChunk(nil), h.chunks...),
168+ tail: cloneHistoryRows(h.tail),
169+ rows: h.rows,
170+ cells: h.cells,
171+ }
172+}
173+
174+func cloneHistoryRows(rows [][]renderer.Cell) [][]renderer.Cell {
175+ if len(rows) == 0 {
176+ return nil
177+ }
178+ copyRows := make([][]renderer.Cell, len(rows))
179+ for i, row := range rows {
180+ copyRows[i] = append([]renderer.Cell(nil), row...)
101181 }
102- return HistoryView{chunks: chunks, rows: h.rows}
182+ return copyRows
103183 }
104184
105185 // Len returns the currently retained history row count.
@@ -110,6 +190,14 @@ func (h *History) Len() int {
110190 return h.rows
111191 }
112192
193+// Cells returns the currently retained history cell count.
194+func (h *History) Cells() int {
195+ if h == nil {
196+ return 0
197+ }
198+ return h.cells
199+}
200+
113201 // Cap returns the configured bounded row capacity.
114202 func (h *History) Cap() int {
115203 if h == nil {
@@ -118,7 +206,16 @@ func (h *History) Cap() int {
118206 return h.maxRows
119207 }
120208
209+// CellCap returns the configured bounded cell capacity.
210+func (h *History) CellCap() int {
211+ if h == nil {
212+ return 0
213+ }
214+ return h.maxCells
215+}
216+
121217 func (v HistoryView) Len() int { return v.rows }
218+func (v HistoryView) Cells() int { return v.cells }
122219 func (v HistoryView) ChunkCount() int { return len(v.chunks) }
123220
124221 // Chunk returns the immutable chunk at i, or nil when i is out of range.
@@ -161,3 +258,31 @@ func (v HistoryView) BorrowedRow(i int) []renderer.Cell {
161258 }
162259 return nil
163260 }
261+
262+func (v HistorySnapshotView) Len() int { return v.rows }
263+func (v HistorySnapshotView) Cells() int { return v.cells }
264+func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) }
265+
266+// Chunk returns a sealed immutable chunk at i, or nil when i is out of range.
267+func (v HistorySnapshotView) Chunk(i int) *HistoryChunk {
268+ if i < 0 || i >= len(v.chunks) {
269+ return nil
270+ }
271+ return v.chunks[i]
272+}
273+
274+// Tail returns an immutable view of the copied mutable tail.
275+func (v HistorySnapshotView) Tail() HistoryView {
276+ if len(v.tail) == 0 {
277+ return HistoryView{}
278+ }
279+ return HistoryView{chunks: []*HistoryChunk{{rows: v.tail}}, rows: len(v.tail), cells: historyRowsCells(v.tail)}
280+}
281+
282+func historyRowsCells(rows [][]renderer.Cell) int {
283+ cells := 0
284+ for _, row := range rows {
285+ cells += len(row)
286+ }
287+ return cells
288+}
@@ -1,10 +1,20 @@
1 package vt1 package vt
2 2
3-import "github.com/bnema/vev/pkg/renderer"3+import (
4+ "errors"
5+ "math"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+// ErrHistoryRowTooWide is returned when a row cannot fit within the configured
11+// cell budget. The history is not modified when this error is returned.
12+var ErrHistoryRowTooWide = errors.New("history row exceeds cell capacity")
4 13
5 // HistoryConfig controls the bounded terminal history retained by a Screen.14 // HistoryConfig controls the bounded terminal history retained by a Screen.
6 type HistoryConfig struct {15 type HistoryConfig struct {
7 MaxRows int16 MaxRows int
17+ MaxCells int
8 ChunkRows int18 ChunkRows int
9 }19 }
10 20
@@ -13,10 +23,12 @@ type HistoryConfig struct {
13 // appends.23 // appends.
14 type History struct {24 type History struct {
15 maxRows int25 maxRows int
26+ maxCells int
16 chunkRows int27 chunkRows int
17 chunks []*HistoryChunk28 chunks []*HistoryChunk
18 tail [][]renderer.Cell29 tail [][]renderer.Cell
19 rows int30 rows int
31+ cells int
20 }32 }
21 33
22 // HistoryChunk is an immutable group of history rows. Its identity is stable34 // HistoryChunk is an immutable group of history rows. Its identity is stable
@@ -30,6 +42,17 @@ type HistoryChunk struct {
30 type HistoryView struct {42 type HistoryView struct {
31 chunks []*HistoryChunk43 chunks []*HistoryChunk
32 rows int44 rows int
45+ cells int
46+}
47+
48+// HistorySnapshotView captures sealed history chunks and the mutable tail
49+// independently. Sealed chunks are shared by identity; Tail is owned by the
50+// view and can be serialized without rotating the live tail into a chunk.
51+type HistorySnapshotView struct {
52+ chunks []*HistoryChunk
53+ tail [][]renderer.Cell
54+ rows int
55+ cells int
33 }56 }
34 57
35 func NewHistory(config HistoryConfig) *History {58 func NewHistory(config HistoryConfig) *History {
@@ -42,20 +65,37 @@ func NewHistory(config HistoryConfig) *History {
42 }65 }
43 chunkRows = min(chunkRows, 256)66 chunkRows = min(chunkRows, 256)
44 chunkRows = min(chunkRows, config.MaxRows)67 chunkRows = min(chunkRows, config.MaxRows)
45- return &History{maxRows: config.MaxRows, chunkRows: chunkRows}68+ maxCells := config.MaxCells
69+ if maxCells <= 0 {
70+ maxCells = defaultHistoryMaxCells(config.MaxRows)
71+ }
72+ return &History{maxRows: config.MaxRows, maxCells: maxCells, chunkRows: chunkRows}
73+}
74+
75+func defaultHistoryMaxCells(maxRows int) int {
76+ if maxRows > math.MaxInt/160 {
77+ return math.MaxInt
78+ }
79+ return maxRows * 160
46 }80 }
47 81
48 // Append records a copy of row. Once a chunk is full it is sealed forever.82 // Append records a copy of row. Once a chunk is full it is sealed forever.
49-func (h *History) Append(row []renderer.Cell) {83+// Rows wider than the total cell capacity are rejected without mutation.
84+func (h *History) Append(row []renderer.Cell) error {
50 if h == nil || h.maxRows == 0 {85 if h == nil || h.maxRows == 0 {
51- return86+ return nil
87+ }
88+ if len(row) > h.maxCells {
89+ return ErrHistoryRowTooWide
52 }90 }
53 h.tail = append(h.tail, append([]renderer.Cell(nil), row...))91 h.tail = append(h.tail, append([]renderer.Cell(nil), row...))
54 h.rows++92 h.rows++
93+ h.cells += len(row)
55 if len(h.tail) == h.chunkRows {94 if len(h.tail) == h.chunkRows {
56 h.sealTail()95 h.sealTail()
57 }96 }
58 h.evict()97 h.evict()
98+ return nil
59 }99 }
60 100
61 func (h *History) sealTail() {101 func (h *History) sealTail() {
@@ -67,12 +107,31 @@ func (h *History) sealTail() {
67 }107 }
68 108
69 func (h *History) evict() {109 func (h *History) evict() {
70- for h.rows > h.maxRows && len(h.chunks) > 0 {110+ for h.rows > h.maxRows || h.cells > h.maxCells {
71- evicted := h.chunks[0]111+ if len(h.chunks) > 0 {
72- h.rows -= len(evicted.rows)112+ chunk := h.chunks[0]
73- copy(h.chunks, h.chunks[1:])113+ row := chunk.rows[0]
74- h.chunks[len(h.chunks)-1] = nil114+ h.rows--
75- h.chunks = h.chunks[:len(h.chunks)-1]115+ h.cells -= len(row)
116+ if len(chunk.rows) == 1 {
117+ copy(h.chunks, h.chunks[1:])
118+ h.chunks[len(h.chunks)-1] = nil
119+ h.chunks = h.chunks[:len(h.chunks)-1]
120+ } else {
121+ // Preserve cell storage while replacing only the chunk wrapper: a
122+ // retained view may still refer to the original immutable chunk.
123+ h.chunks[0] = &HistoryChunk{rows: chunk.rows[1:]}
124+ }
125+ continue
126+ }
127+ if len(h.tail) == 0 {
128+ return
129+ }
130+ row := h.tail[0]
131+ h.rows--
132+ h.cells -= len(row)
133+ h.tail[0] = nil
134+ h.tail = h.tail[1:]
76 }135 }
77 }136 }
78 137
@@ -93,13 +152,34 @@ func (h *History) View() HistoryView {
93 }152 }
94 chunks := append([]*HistoryChunk(nil), h.chunks...)153 chunks := append([]*HistoryChunk(nil), h.chunks...)
95 if len(h.tail) > 0 {154 if len(h.tail) > 0 {
96- rows := make([][]renderer.Cell, len(h.tail))155+ chunks = append(chunks, &HistoryChunk{rows: cloneHistoryRows(h.tail)})
97- for i, row := range h.tail {156+ }
98- rows[i] = append([]renderer.Cell(nil), row...)157+ return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells}
99- }158+}
100- chunks = append(chunks, &HistoryChunk{rows: rows})159+
160+// SnapshotView captures history for persistence without sealing the mutable
161+// tail. Sealed chunks are shared by identity and the tail is deeply copied.
162+func (h *History) SnapshotView() HistorySnapshotView {
163+ if h == nil || h.rows == 0 {
164+ return HistorySnapshotView{}
165+ }
166+ return HistorySnapshotView{
167+ chunks: append([]*HistoryChunk(nil), h.chunks...),
168+ tail: cloneHistoryRows(h.tail),
169+ rows: h.rows,
170+ cells: h.cells,
171+ }
172+}
173+
174+func cloneHistoryRows(rows [][]renderer.Cell) [][]renderer.Cell {
175+ if len(rows) == 0 {
176+ return nil
177+ }
178+ copyRows := make([][]renderer.Cell, len(rows))
179+ for i, row := range rows {
180+ copyRows[i] = append([]renderer.Cell(nil), row...)
101 }181 }
102- return HistoryView{chunks: chunks, rows: h.rows}182+ return copyRows
103 }183 }
104 184
105 // Len returns the currently retained history row count.185 // Len returns the currently retained history row count.
@@ -110,6 +190,14 @@ func (h *History) Len() int {
110 return h.rows190 return h.rows
111 }191 }
112 192
193+// Cells returns the currently retained history cell count.
194+func (h *History) Cells() int {
195+ if h == nil {
196+ return 0
197+ }
198+ return h.cells
199+}
200+
113 // Cap returns the configured bounded row capacity.201 // Cap returns the configured bounded row capacity.
114 func (h *History) Cap() int {202 func (h *History) Cap() int {
115 if h == nil {203 if h == nil {
@@ -118,7 +206,16 @@ func (h *History) Cap() int {
118 return h.maxRows206 return h.maxRows
119 }207 }
120 208
209+// CellCap returns the configured bounded cell capacity.
210+func (h *History) CellCap() int {
211+ if h == nil {
212+ return 0
213+ }
214+ return h.maxCells
215+}
216+
121 func (v HistoryView) Len() int { return v.rows }217 func (v HistoryView) Len() int { return v.rows }
218+func (v HistoryView) Cells() int { return v.cells }
122 func (v HistoryView) ChunkCount() int { return len(v.chunks) }219 func (v HistoryView) ChunkCount() int { return len(v.chunks) }
123 220
124 // Chunk returns the immutable chunk at i, or nil when i is out of range.221 // Chunk returns the immutable chunk at i, or nil when i is out of range.
@@ -161,3 +258,31 @@ func (v HistoryView) BorrowedRow(i int) []renderer.Cell {
161 }258 }
162 return nil259 return nil
163 }260 }
261+
262+func (v HistorySnapshotView) Len() int { return v.rows }
263+func (v HistorySnapshotView) Cells() int { return v.cells }
264+func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) }
265+
266+// Chunk returns a sealed immutable chunk at i, or nil when i is out of range.
267+func (v HistorySnapshotView) Chunk(i int) *HistoryChunk {
268+ if i < 0 || i >= len(v.chunks) {
269+ return nil
270+ }
271+ return v.chunks[i]
272+}
273+
274+// Tail returns an immutable view of the copied mutable tail.
275+func (v HistorySnapshotView) Tail() HistoryView {
276+ if len(v.tail) == 0 {
277+ return HistoryView{}
278+ }
279+ return HistoryView{chunks: []*HistoryChunk{{rows: v.tail}}, rows: len(v.tail), cells: historyRowsCells(v.tail)}
280+}
281+
282+func historyRowsCells(rows [][]renderer.Cell) int {
283+ cells := 0
284+ for _, row := range rows {
285+ cells += len(row)
286+ }
287+ return cells
288+}
M pkg/vt/history_test.go +72 -2
@@ -1,6 +1,8 @@
11 package vt
22
33 import (
4+ "errors"
5+ "math"
46 "testing"
57
68 "github.com/bnema/vev/pkg/renderer"
@@ -22,11 +24,11 @@ func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) {
2224 want: []string{"cccc", "dddd", "eeee", "ffff"},
2325 },
2426 {
25- name: "nonmultiple capacity evicts a whole oldest immutable chunk",
27+ name: "nonmultiple capacity evicts exactly the oldest row",
2628 maxRows: 5,
2729 chunkRows: 2,
2830 rows: []string{"0000", "1111", "2222", "3333", "4444", "5555"},
29- want: []string{"2222", "3333", "4444", "5555"},
31+ want: []string{"1111", "2222", "3333", "4444", "5555"},
3032 },
3133 {
3234 name: "rows preserve append order across chunk boundaries",
@@ -192,6 +194,74 @@ func TestHistoryRecordsOnlyTopEdgeScrollEvictions(t *testing.T) {
192194 }
193195 }
194196
197+func TestHistoryBoundsRowsAndCellsWithExactRowEviction(t *testing.T) {
198+ history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 5, ChunkRows: 2})
199+ for _, text := range []string{"aa", "bbb", "c", "dd"} {
200+ if err := history.Append(historyRow(text)); err != nil {
201+ t.Fatalf("append %q: %v", text, err)
202+ }
203+ }
204+
205+ view := history.View()
206+ if got, want := historyViewTexts(view), []string{"c", "dd"}; !equalStrings(got, want) {
207+ t.Fatalf("retained rows = %#v, want %#v", got, want)
208+ }
209+ if got, want := history.Len(), 2; got != want {
210+ t.Fatalf("retained rows = %d, want %d", got, want)
211+ }
212+ if got, want := history.Cells(), 3; got != want {
213+ t.Fatalf("retained cells = %d, want %d", got, want)
214+ }
215+ if got, want := view.Cells(), 3; got != want {
216+ t.Fatalf("view cells = %d, want %d", got, want)
217+ }
218+}
219+
220+func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) {
221+ history := NewHistory(HistoryConfig{MaxRows: 2, MaxCells: 2, ChunkRows: 2})
222+ if err := history.Append(historyRow("ok")); err != nil {
223+ t.Fatalf("append retained row: %v", err)
224+ }
225+ before := history.View()
226+
227+ err := history.Append(historyRow("wide"))
228+ if !errors.Is(err, ErrHistoryRowTooWide) {
229+ t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooWide", err)
230+ }
231+ if got, want := historyViewTexts(history.View()), historyViewTexts(before); !equalStrings(got, want) {
232+ t.Fatalf("history mutated after rejected append: got %#v, want %#v", got, want)
233+ }
234+ if got, want := history.Cells(), 2; got != want {
235+ t.Fatalf("retained cells after rejected append = %d, want %d", got, want)
236+ }
237+}
238+
239+func TestScreenDropsOversizedHistoryRowsWithoutInterruptingScroll(t *testing.T) {
240+ screen := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 2, MaxCells: 3})
241+ events := 0
242+ screen.OnLineEvicted = func([]renderer.Cell) { events++ }
243+ copy(screen.Frame.Row(0), historyRow("AAAA"))
244+ copy(screen.Frame.Row(1), historyRow("BBBB"))
245+ screen.scrollUpRegion(0, 1, 1)
246+
247+ if got := screen.History().Len(); got != 0 {
248+ t.Fatalf("oversized scrollback rows = %d, want 0", got)
249+ }
250+ if got := events; got != 1 {
251+ t.Fatalf("scroll callbacks = %d, want 1", got)
252+ }
253+ if got := lineText(screen, 0); got != "BBBB" {
254+ t.Fatalf("screen did not scroll after history drop: row 0 = %q", got)
255+ }
256+}
257+
258+func TestHistoryDefaultCellBudgetDoesNotOverflow(t *testing.T) {
259+ history := NewHistory(HistoryConfig{MaxRows: math.MaxInt, ChunkRows: 1})
260+ if got, want := history.CellCap(), math.MaxInt; got != want {
261+ t.Fatalf("default cell cap = %d, want %d", got, want)
262+ }
263+}
264+
195265 func historyRow(text string) []renderer.Cell {
196266 row := make([]renderer.Cell, len(text))
197267 for i, r := range text {
@@ -1,6 +1,8 @@
1 package vt1 package vt
2 2
3 import (3 import (
4+ "errors"
5+ "math"
4 "testing"6 "testing"
5 7
6 "github.com/bnema/vev/pkg/renderer"8 "github.com/bnema/vev/pkg/renderer"
@@ -22,11 +24,11 @@ func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) {
22 want: []string{"cccc", "dddd", "eeee", "ffff"},24 want: []string{"cccc", "dddd", "eeee", "ffff"},
23 },25 },
24 {26 {
25- name: "nonmultiple capacity evicts a whole oldest immutable chunk",27+ name: "nonmultiple capacity evicts exactly the oldest row",
26 maxRows: 5,28 maxRows: 5,
27 chunkRows: 2,29 chunkRows: 2,
28 rows: []string{"0000", "1111", "2222", "3333", "4444", "5555"},30 rows: []string{"0000", "1111", "2222", "3333", "4444", "5555"},
29- want: []string{"2222", "3333", "4444", "5555"},31+ want: []string{"1111", "2222", "3333", "4444", "5555"},
30 },32 },
31 {33 {
32 name: "rows preserve append order across chunk boundaries",34 name: "rows preserve append order across chunk boundaries",
@@ -192,6 +194,74 @@ func TestHistoryRecordsOnlyTopEdgeScrollEvictions(t *testing.T) {
192 }194 }
193 }195 }
194 196
197+func TestHistoryBoundsRowsAndCellsWithExactRowEviction(t *testing.T) {
198+ history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 5, ChunkRows: 2})
199+ for _, text := range []string{"aa", "bbb", "c", "dd"} {
200+ if err := history.Append(historyRow(text)); err != nil {
201+ t.Fatalf("append %q: %v", text, err)
202+ }
203+ }
204+
205+ view := history.View()
206+ if got, want := historyViewTexts(view), []string{"c", "dd"}; !equalStrings(got, want) {
207+ t.Fatalf("retained rows = %#v, want %#v", got, want)
208+ }
209+ if got, want := history.Len(), 2; got != want {
210+ t.Fatalf("retained rows = %d, want %d", got, want)
211+ }
212+ if got, want := history.Cells(), 3; got != want {
213+ t.Fatalf("retained cells = %d, want %d", got, want)
214+ }
215+ if got, want := view.Cells(), 3; got != want {
216+ t.Fatalf("view cells = %d, want %d", got, want)
217+ }
218+}
219+
220+func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) {
221+ history := NewHistory(HistoryConfig{MaxRows: 2, MaxCells: 2, ChunkRows: 2})
222+ if err := history.Append(historyRow("ok")); err != nil {
223+ t.Fatalf("append retained row: %v", err)
224+ }
225+ before := history.View()
226+
227+ err := history.Append(historyRow("wide"))
228+ if !errors.Is(err, ErrHistoryRowTooWide) {
229+ t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooWide", err)
230+ }
231+ if got, want := historyViewTexts(history.View()), historyViewTexts(before); !equalStrings(got, want) {
232+ t.Fatalf("history mutated after rejected append: got %#v, want %#v", got, want)
233+ }
234+ if got, want := history.Cells(), 2; got != want {
235+ t.Fatalf("retained cells after rejected append = %d, want %d", got, want)
236+ }
237+}
238+
239+func TestScreenDropsOversizedHistoryRowsWithoutInterruptingScroll(t *testing.T) {
240+ screen := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 2, MaxCells: 3})
241+ events := 0
242+ screen.OnLineEvicted = func([]renderer.Cell) { events++ }
243+ copy(screen.Frame.Row(0), historyRow("AAAA"))
244+ copy(screen.Frame.Row(1), historyRow("BBBB"))
245+ screen.scrollUpRegion(0, 1, 1)
246+
247+ if got := screen.History().Len(); got != 0 {
248+ t.Fatalf("oversized scrollback rows = %d, want 0", got)
249+ }
250+ if got := events; got != 1 {
251+ t.Fatalf("scroll callbacks = %d, want 1", got)
252+ }
253+ if got := lineText(screen, 0); got != "BBBB" {
254+ t.Fatalf("screen did not scroll after history drop: row 0 = %q", got)
255+ }
256+}
257+
258+func TestHistoryDefaultCellBudgetDoesNotOverflow(t *testing.T) {
259+ history := NewHistory(HistoryConfig{MaxRows: math.MaxInt, ChunkRows: 1})
260+ if got, want := history.CellCap(), math.MaxInt; got != want {
261+ t.Fatalf("default cell cap = %d, want %d", got, want)
262+ }
263+}
264+
195 func historyRow(text string) []renderer.Cell {265 func historyRow(text string) []renderer.Cell {
196 row := make([]renderer.Cell, len(text))266 row := make([]renderer.Cell, len(text))
197 for i, r := range text {267 for i, r := range text {
M pkg/vt/screen_scroll.go +3 -1
@@ -88,7 +88,9 @@ func (s *Screen) emitLineEvicted(top, n int) {
8888
8989 func (s *Screen) recordEvicted(row []renderer.Cell) {
9090 if s.history != nil {
91- s.history.Append(row)
91+ // Oversized rows cannot fit the configured cell budget. Scrolling must
92+ // still complete (and notify observers), so history drops that row.
93+ _ = s.history.Append(row)
9294 }
9395 if s.OnLineEvicted != nil {
9496 s.OnLineEvicted(append([]renderer.Cell(nil), row...))
@@ -88,7 +88,9 @@ func (s *Screen) emitLineEvicted(top, n int) {
88 88
89 func (s *Screen) recordEvicted(row []renderer.Cell) {89 func (s *Screen) recordEvicted(row []renderer.Cell) {
90 if s.history != nil {90 if s.history != nil {
91- s.history.Append(row)91+ // Oversized rows cannot fit the configured cell budget. Scrolling must
92+ // still complete (and notify observers), so history drops that row.
93+ _ = s.history.Append(row)
92 }94 }
93 if s.OnLineEvicted != nil {95 if s.OnLineEvicted != nil {
94 s.OnLineEvicted(append([]renderer.Cell(nil), row...))96 s.OnLineEvicted(append([]renderer.Cell(nil), row...))

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