detached 30aa2bc

perf(history): add allocation-free text scans and pooled page reads

HistoryView.RangeText streams each row's runes, cell columns and width
through reused buffers, reading stored runes directly instead of
materializing styled cells. Cold page restores reuse zlib readers and
read straight into an exactly sized buffer; truncation, trailing data
and checksum failures are still rejected as ErrHistoryCorrupt.
commit
30aa2bccf218bffe15c13ee59ead7d1bf0dbb685
author
brice · · 2026-10-07 16:29 UTC
parent
61dc249
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +137 -8
M core/frame.go +23 -0
@@ -267,6 +267,29 @@ func (f Frame) Cell(x, y int) Cell {
267267 }
268268 func (f Frame) At(x, y int) Cell { return f.Cell(x, y) }
269269
270+// AppendRowText appends one rune per non-continuation cell of row y to runes,
271+// and that cell's column to columns, and returns both extended slices. A zero
272+// rune is reported as a space. It reads only runes and flags, so text scans
273+// avoid materializing styled cells.
274+func (f Frame) AppendRowText(runes []rune, columns []int, y int) ([]rune, []int) {
275+ if f.Width == 0 {
276+ return runes, columns
277+ }
278+ start := f.offset(0, y)
279+ for x, stored := range f.page.cells[start : start+f.Width] {
280+ if stored.flags&continuationFlag != 0 {
281+ continue
282+ }
283+ r := rune(stored.rune)
284+ if r == 0 {
285+ r = ' '
286+ }
287+ runes = append(runes, r)
288+ columns = append(columns, x)
289+ }
290+ return runes, columns
291+}
292+
270293 // IsContinuation reports whether (x,y) is the right half of a wide pair
271294 // without materializing the cell.
272295 func (f Frame) IsContinuation(x, y int) bool {
@@ -267,6 +267,29 @@ func (f Frame) Cell(x, y int) Cell {
267 }267 }
268 func (f Frame) At(x, y int) Cell { return f.Cell(x, y) }268 func (f Frame) At(x, y int) Cell { return f.Cell(x, y) }
269 269
270+// AppendRowText appends one rune per non-continuation cell of row y to runes,
271+// and that cell's column to columns, and returns both extended slices. A zero
272+// rune is reported as a space. It reads only runes and flags, so text scans
273+// avoid materializing styled cells.
274+func (f Frame) AppendRowText(runes []rune, columns []int, y int) ([]rune, []int) {
275+ if f.Width == 0 {
276+ return runes, columns
277+ }
278+ start := f.offset(0, y)
279+ for x, stored := range f.page.cells[start : start+f.Width] {
280+ if stored.flags&continuationFlag != 0 {
281+ continue
282+ }
283+ r := rune(stored.rune)
284+ if r == 0 {
285+ r = ' '
286+ }
287+ runes = append(runes, r)
288+ columns = append(columns, x)
289+ }
290+ return runes, columns
291+}
292+
270 // IsContinuation reports whether (x,y) is the right half of a wide pair293 // IsContinuation reports whether (x,y) is the right half of a wide pair
271 // without materializing the cell.294 // without materializing the cell.
272 func (f Frame) IsContinuation(x, y int) bool {295 func (f Frame) IsContinuation(x, y int) bool {
M history_evict_inplace_test.go +58 -0
@@ -105,6 +105,64 @@ func TestHistoryCompressIdleTrimsTailCapacity(t *testing.T) {
105105 }
106106 }
107107
108+// RangeText must agree with Range on every row, including wide pairs, zero
109+// runes, evicted prefixes and cold pages.
110+func TestHistoryRangeTextMatchesRange(t *testing.T) {
111+ build := func(cold bool) HistoryView {
112+ h := NewHistory(HistoryConfig{MaxRows: 20, MaxBytes: 1 << 20, ChunkRows: 4})
113+ for i := range 27 {
114+ row := historyRow(fmt.Sprintf("r%02d", i))
115+ row = append(row,
116+ renderer.Cell{Rune: '界'}, renderer.Cell{Continuation: true}, // wide pair
117+ renderer.Cell{}, // zero rune reads as a space
118+ renderer.Cell{Rune: 'x'})
119+ require.NoError(t, h.Append(row, LineBound{End: len(row)}))
120+ }
121+ if cold {
122+ for range 2 {
123+ _, err := h.CompressIdle(100)
124+ require.NoError(t, err)
125+ }
126+ }
127+ return h.View()
128+ }
129+ for _, cold := range []bool{false, true} {
130+ t.Run(fmt.Sprintf("cold-%v", cold), func(t *testing.T) {
131+ view := build(cold)
132+ type rowText struct {
133+ runes []rune
134+ columns []int
135+ width int
136+ }
137+ var want []rowText
138+ require.NoError(t, view.Range(func(row []renderer.Cell) bool {
139+ w := rowText{width: len(row)}
140+ for x, c := range row {
141+ if c.Continuation {
142+ continue
143+ }
144+ r := c.Rune
145+ if r == 0 {
146+ r = ' '
147+ }
148+ w.runes = append(w.runes, r)
149+ w.columns = append(w.columns, x)
150+ }
151+ want = append(want, w)
152+ return true
153+ }))
154+ var got []rowText
155+ require.NoError(t, view.RangeText(func(i int, runes []rune, columns []int, width int) bool {
156+ require.Equal(t, len(got), i)
157+ got = append(got, rowText{append([]rune(nil), runes...), append([]int(nil), columns...), width})
158+ return true
159+ }))
160+ require.Equal(t, want, got)
161+ require.Len(t, got, 20)
162+ })
163+ }
164+}
165+
108166 func TestStyleSet(t *testing.T) {
109167 style := func(i int) renderer.Style { return renderer.Style{Foreground: i % 256, Bold: i >= 256} }
110168 tests := []struct {
@@ -105,6 +105,64 @@ func TestHistoryCompressIdleTrimsTailCapacity(t *testing.T) {
105 }105 }
106 }106 }
107 107
108+// RangeText must agree with Range on every row, including wide pairs, zero
109+// runes, evicted prefixes and cold pages.
110+func TestHistoryRangeTextMatchesRange(t *testing.T) {
111+ build := func(cold bool) HistoryView {
112+ h := NewHistory(HistoryConfig{MaxRows: 20, MaxBytes: 1 << 20, ChunkRows: 4})
113+ for i := range 27 {
114+ row := historyRow(fmt.Sprintf("r%02d", i))
115+ row = append(row,
116+ renderer.Cell{Rune: '界'}, renderer.Cell{Continuation: true}, // wide pair
117+ renderer.Cell{}, // zero rune reads as a space
118+ renderer.Cell{Rune: 'x'})
119+ require.NoError(t, h.Append(row, LineBound{End: len(row)}))
120+ }
121+ if cold {
122+ for range 2 {
123+ _, err := h.CompressIdle(100)
124+ require.NoError(t, err)
125+ }
126+ }
127+ return h.View()
128+ }
129+ for _, cold := range []bool{false, true} {
130+ t.Run(fmt.Sprintf("cold-%v", cold), func(t *testing.T) {
131+ view := build(cold)
132+ type rowText struct {
133+ runes []rune
134+ columns []int
135+ width int
136+ }
137+ var want []rowText
138+ require.NoError(t, view.Range(func(row []renderer.Cell) bool {
139+ w := rowText{width: len(row)}
140+ for x, c := range row {
141+ if c.Continuation {
142+ continue
143+ }
144+ r := c.Rune
145+ if r == 0 {
146+ r = ' '
147+ }
148+ w.runes = append(w.runes, r)
149+ w.columns = append(w.columns, x)
150+ }
151+ want = append(want, w)
152+ return true
153+ }))
154+ var got []rowText
155+ require.NoError(t, view.RangeText(func(i int, runes []rune, columns []int, width int) bool {
156+ require.Equal(t, len(got), i)
157+ got = append(got, rowText{append([]rune(nil), runes...), append([]int(nil), columns...), width})
158+ return true
159+ }))
160+ require.Equal(t, want, got)
161+ require.Len(t, got, 20)
162+ })
163+ }
164+}
165+
108 func TestStyleSet(t *testing.T) {166 func TestStyleSet(t *testing.T) {
109 style := func(i int) renderer.Style { return renderer.Style{Foreground: i % 256, Bold: i >= 256} }167 style := func(i int) renderer.Style { return renderer.Style{Foreground: i % 256, Bold: i >= 256} }
110 tests := []struct {168 tests := []struct {
M history_read.go +26 -0
@@ -38,6 +38,32 @@ func (v HistoryView) CopyRow(y int, dst []renderer.Cell) int {
3838 return n
3939 }
4040
41+// RangeText streams each row's text in order without allocating per row. For
42+// row i of the given cell width, runes holds one rune per non-continuation
43+// cell (zero runes read as a space) and columns holds the cell column of each
44+// rune. Both slices are only valid during the callback. Like Range, it
45+// restores at most one cold page at a time and does not populate the page
46+// cache.
47+func (v HistoryView) RangeText(yield func(i int, runes []rune, columns []int, width int) bool) error {
48+ var runes []rune
49+ var columns []int
50+ i := 0
51+ for _, chunk := range v.chunks {
52+ frame, err := chunk.page.readFrame(false)
53+ if err != nil {
54+ return err
55+ }
56+ for row := range chunk.len() {
57+ runes, columns = frame.AppendRowText(runes[:0], columns[:0], chunk.start+row)
58+ if !yield(i, runes, columns, chunk.width) {
59+ return nil
60+ }
61+ i++
62+ }
63+ }
64+ return nil
65+}
66+
4167 func (v HistoryView) locateRow(y int) (*HistoryChunk, int) {
4268 if y < 0 {
4369 return nil, 0
@@ -38,6 +38,32 @@ func (v HistoryView) CopyRow(y int, dst []renderer.Cell) int {
38 return n38 return n
39 }39 }
40 40
41+// RangeText streams each row's text in order without allocating per row. For
42+// row i of the given cell width, runes holds one rune per non-continuation
43+// cell (zero runes read as a space) and columns holds the cell column of each
44+// rune. Both slices are only valid during the callback. Like Range, it
45+// restores at most one cold page at a time and does not populate the page
46+// cache.
47+func (v HistoryView) RangeText(yield func(i int, runes []rune, columns []int, width int) bool) error {
48+ var runes []rune
49+ var columns []int
50+ i := 0
51+ for _, chunk := range v.chunks {
52+ frame, err := chunk.page.readFrame(false)
53+ if err != nil {
54+ return err
55+ }
56+ for row := range chunk.len() {
57+ runes, columns = frame.AppendRowText(runes[:0], columns[:0], chunk.start+row)
58+ if !yield(i, runes, columns, chunk.width) {
59+ return nil
60+ }
61+ i++
62+ }
63+ }
64+ return nil
65+}
66+
41 func (v HistoryView) locateRow(y int) (*HistoryChunk, int) {67 func (v HistoryView) locateRow(y int) (*HistoryChunk, int) {
42 if y < 0 {68 if y < 0 {
43 return nil, 069 return nil, 0
M history_storage.go +30 -8
@@ -65,19 +65,28 @@ func (p *sealedPage) decode() (renderer.Frame, error) {
6565 return invalid(errors.New("missing backing"))
6666 }
6767 source := bytes.NewReader(p.compressed)
68- r, err := zlib.NewReader(source)
68+ r, err := pageDecompressor(source)
6969 if err != nil {
7070 return invalid(err)
7171 }
72- data, readErr := io.ReadAll(io.LimitReader(r, int64(p.encodedSize)+1))
73- closeErr := r.Close()
74- if readErr != nil {
75- return invalid(readErr)
72+ defer pageDecompressorPool.Put(r)
73+ // Read exactly the recorded size, then require a clean end of stream (which
74+ // also verifies the zlib checksum) with no trailing compressed bytes.
75+ data := make([]byte, p.encodedSize)
76+ if _, err := io.ReadFull(r, data); err != nil {
77+ return invalid(err)
7678 }
77- if closeErr != nil {
78- return invalid(closeErr)
79+ var extra [1]byte
80+ if n, err := r.Read(extra[:]); n != 0 || err != io.EOF {
81+ if err == nil || err == io.EOF {
82+ err = errors.New("backing length mismatch")
83+ }
84+ return invalid(err)
85+ }
86+ if err := r.Close(); err != nil {
87+ return invalid(err)
7988 }
80- if len(data) != p.encodedSize || source.Len() != 0 {
89+ if source.Len() != 0 {
8190 return invalid(errors.New("backing length mismatch"))
8291 }
8392 view, err := UnmarshalHistory(data)
@@ -113,6 +122,19 @@ func (c *HistoryChunk) Restore() error {
113122 return err
114123 }
115124
125+// pageDecompressorPool reuses zlib readers; each one owns a 32 KiB window.
126+var pageDecompressorPool sync.Pool
127+
128+func pageDecompressor(source io.Reader) (io.ReadCloser, error) {
129+ if pooled, ok := pageDecompressorPool.Get().(io.ReadCloser); ok {
130+ if err := pooled.(zlib.Resetter).Reset(source, nil); err != nil {
131+ return nil, err
132+ }
133+ return pooled, nil
134+ }
135+ return zlib.NewReader(source)
136+}
137+
116138 // pageCompressorPool reuses BestSpeed writers; each one owns ~600 KiB of
117139 // deflate state that would otherwise be allocated per compressed page.
118140 var pageCompressorPool = sync.Pool{New: func() any {
@@ -65,19 +65,28 @@ func (p *sealedPage) decode() (renderer.Frame, error) {
65 return invalid(errors.New("missing backing"))65 return invalid(errors.New("missing backing"))
66 }66 }
67 source := bytes.NewReader(p.compressed)67 source := bytes.NewReader(p.compressed)
68- r, err := zlib.NewReader(source)68+ r, err := pageDecompressor(source)
69 if err != nil {69 if err != nil {
70 return invalid(err)70 return invalid(err)
71 }71 }
72- data, readErr := io.ReadAll(io.LimitReader(r, int64(p.encodedSize)+1))72+ defer pageDecompressorPool.Put(r)
73- closeErr := r.Close()73+ // Read exactly the recorded size, then require a clean end of stream (which
74- if readErr != nil {74+ // also verifies the zlib checksum) with no trailing compressed bytes.
75- return invalid(readErr)75+ data := make([]byte, p.encodedSize)
76+ if _, err := io.ReadFull(r, data); err != nil {
77+ return invalid(err)
76 }78 }
77- if closeErr != nil {79+ var extra [1]byte
78- return invalid(closeErr)80+ if n, err := r.Read(extra[:]); n != 0 || err != io.EOF {
81+ if err == nil || err == io.EOF {
82+ err = errors.New("backing length mismatch")
83+ }
84+ return invalid(err)
85+ }
86+ if err := r.Close(); err != nil {
87+ return invalid(err)
79 }88 }
80- if len(data) != p.encodedSize || source.Len() != 0 {89+ if source.Len() != 0 {
81 return invalid(errors.New("backing length mismatch"))90 return invalid(errors.New("backing length mismatch"))
82 }91 }
83 view, err := UnmarshalHistory(data)92 view, err := UnmarshalHistory(data)
@@ -113,6 +122,19 @@ func (c *HistoryChunk) Restore() error {
113 return err122 return err
114 }123 }
115 124
125+// pageDecompressorPool reuses zlib readers; each one owns a 32 KiB window.
126+var pageDecompressorPool sync.Pool
127+
128+func pageDecompressor(source io.Reader) (io.ReadCloser, error) {
129+ if pooled, ok := pageDecompressorPool.Get().(io.ReadCloser); ok {
130+ if err := pooled.(zlib.Resetter).Reset(source, nil); err != nil {
131+ return nil, err
132+ }
133+ return pooled, nil
134+ }
135+ return zlib.NewReader(source)
136+}
137+
116 // pageCompressorPool reuses BestSpeed writers; each one owns ~600 KiB of138 // pageCompressorPool reuses BestSpeed writers; each one owns ~600 KiB of
117 // deflate state that would otherwise be allocated per compressed page.139 // deflate state that would otherwise be allocated per compressed page.
118 var pageCompressorPool = sync.Pool{New: func() any {140 var pageCompressorPool = sync.Pool{New: func() any {
M vev-vt.test +0 -0 binary file: no text diff

binary file: no text diff

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