detached e8f2961

refactor(vt): own immutable terminal history chunks

commit
e8f2961ff9a2a77e1bd05664abfd95e091feba81
author
brice · · 2026-07-12 15:30 UTC
parent
bf359d3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +362 -7
A pkg/vt/history.go +119 -0
@@ -0,0 +1,119 @@
1+package vt
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+// HistoryConfig controls the bounded terminal history retained by a Screen.
6+type HistoryConfig struct {
7+ MaxRows int
8+ ChunkRows int
9+}
10+
11+// History stores terminal rows in immutable chunks. It is intended to be
12+// mutated by the owner of a Screen; views are safe to retain after later
13+// appends.
14+type History struct {
15+ maxRows int
16+ chunkRows int
17+ chunks []*HistoryChunk
18+ tail [][]renderer.Cell
19+ rows int
20+}
21+
22+// HistoryChunk is an immutable group of history rows. Its identity is stable
23+// and can be used by consumers to avoid copying unchanged sealed chunks.
24+type HistoryChunk struct {
25+ rows [][]renderer.Cell
26+}
27+
28+// HistoryView is an immutable snapshot of history. Row returns a copy so the
29+// storage behind a sealed chunk remains owned by VT.
30+type HistoryView struct {
31+ chunks []*HistoryChunk
32+ rows int
33+}
34+
35+func NewHistory(config HistoryConfig) *History {
36+ if config.MaxRows <= 0 {
37+ return &History{}
38+ }
39+ chunkRows := config.ChunkRows
40+ if chunkRows <= 0 {
41+ chunkRows = 256
42+ }
43+ chunkRows = min(chunkRows, 256)
44+ chunkRows = min(chunkRows, config.MaxRows)
45+ return &History{maxRows: config.MaxRows, chunkRows: chunkRows}
46+}
47+
48+// Append records a copy of row. Once a chunk is full it is sealed forever.
49+func (h *History) Append(row []renderer.Cell) {
50+ if h == nil || h.maxRows == 0 {
51+ return
52+ }
53+ h.tail = append(h.tail, append([]renderer.Cell(nil), row...))
54+ h.rows++
55+ if len(h.tail) == h.chunkRows {
56+ h.sealTail()
57+ }
58+ h.evict()
59+}
60+
61+func (h *History) sealTail() {
62+ if len(h.tail) == 0 {
63+ return
64+ }
65+ h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail})
66+ h.tail = nil
67+}
68+
69+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]
76+ }
77+}
78+
79+// View captures the current history. Sealed chunks are shared by identity; a
80+// partially-filled tail is copied into a new immutable chunk for this view.
81+func (h *History) View() HistoryView {
82+ if h == nil || h.rows == 0 {
83+ return HistoryView{}
84+ }
85+ chunks := append([]*HistoryChunk(nil), h.chunks...)
86+ if len(h.tail) > 0 {
87+ rows := make([][]renderer.Cell, len(h.tail))
88+ for i, row := range h.tail {
89+ rows[i] = append([]renderer.Cell(nil), row...)
90+ }
91+ chunks = append(chunks, &HistoryChunk{rows: rows})
92+ }
93+ return HistoryView{chunks: chunks, rows: h.rows}
94+}
95+
96+func (v HistoryView) Len() int { return v.rows }
97+func (v HistoryView) ChunkCount() int { return len(v.chunks) }
98+
99+// Chunk returns the immutable chunk at i, or nil when i is out of range.
100+func (v HistoryView) Chunk(i int) *HistoryChunk {
101+ if i < 0 || i >= len(v.chunks) {
102+ return nil
103+ }
104+ return v.chunks[i]
105+}
106+
107+// Row returns a copy of the row at i, or nil when i is out of range.
108+func (v HistoryView) Row(i int) []renderer.Cell {
109+ if i < 0 {
110+ return nil
111+ }
112+ for _, chunk := range v.chunks {
113+ if i < len(chunk.rows) {
114+ return append([]renderer.Cell(nil), chunk.rows[i]...)
115+ }
116+ i -= len(chunk.rows)
117+ }
118+ return nil
119+}
@@ -0,0 +1,119 @@
1+package vt
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+// HistoryConfig controls the bounded terminal history retained by a Screen.
6+type HistoryConfig struct {
7+ MaxRows int
8+ ChunkRows int
9+}
10+
11+// History stores terminal rows in immutable chunks. It is intended to be
12+// mutated by the owner of a Screen; views are safe to retain after later
13+// appends.
14+type History struct {
15+ maxRows int
16+ chunkRows int
17+ chunks []*HistoryChunk
18+ tail [][]renderer.Cell
19+ rows int
20+}
21+
22+// HistoryChunk is an immutable group of history rows. Its identity is stable
23+// and can be used by consumers to avoid copying unchanged sealed chunks.
24+type HistoryChunk struct {
25+ rows [][]renderer.Cell
26+}
27+
28+// HistoryView is an immutable snapshot of history. Row returns a copy so the
29+// storage behind a sealed chunk remains owned by VT.
30+type HistoryView struct {
31+ chunks []*HistoryChunk
32+ rows int
33+}
34+
35+func NewHistory(config HistoryConfig) *History {
36+ if config.MaxRows <= 0 {
37+ return &History{}
38+ }
39+ chunkRows := config.ChunkRows
40+ if chunkRows <= 0 {
41+ chunkRows = 256
42+ }
43+ chunkRows = min(chunkRows, 256)
44+ chunkRows = min(chunkRows, config.MaxRows)
45+ return &History{maxRows: config.MaxRows, chunkRows: chunkRows}
46+}
47+
48+// Append records a copy of row. Once a chunk is full it is sealed forever.
49+func (h *History) Append(row []renderer.Cell) {
50+ if h == nil || h.maxRows == 0 {
51+ return
52+ }
53+ h.tail = append(h.tail, append([]renderer.Cell(nil), row...))
54+ h.rows++
55+ if len(h.tail) == h.chunkRows {
56+ h.sealTail()
57+ }
58+ h.evict()
59+}
60+
61+func (h *History) sealTail() {
62+ if len(h.tail) == 0 {
63+ return
64+ }
65+ h.chunks = append(h.chunks, &HistoryChunk{rows: h.tail})
66+ h.tail = nil
67+}
68+
69+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]
76+ }
77+}
78+
79+// View captures the current history. Sealed chunks are shared by identity; a
80+// partially-filled tail is copied into a new immutable chunk for this view.
81+func (h *History) View() HistoryView {
82+ if h == nil || h.rows == 0 {
83+ return HistoryView{}
84+ }
85+ chunks := append([]*HistoryChunk(nil), h.chunks...)
86+ if len(h.tail) > 0 {
87+ rows := make([][]renderer.Cell, len(h.tail))
88+ for i, row := range h.tail {
89+ rows[i] = append([]renderer.Cell(nil), row...)
90+ }
91+ chunks = append(chunks, &HistoryChunk{rows: rows})
92+ }
93+ return HistoryView{chunks: chunks, rows: h.rows}
94+}
95+
96+func (v HistoryView) Len() int { return v.rows }
97+func (v HistoryView) ChunkCount() int { return len(v.chunks) }
98+
99+// Chunk returns the immutable chunk at i, or nil when i is out of range.
100+func (v HistoryView) Chunk(i int) *HistoryChunk {
101+ if i < 0 || i >= len(v.chunks) {
102+ return nil
103+ }
104+ return v.chunks[i]
105+}
106+
107+// Row returns a copy of the row at i, or nil when i is out of range.
108+func (v HistoryView) Row(i int) []renderer.Cell {
109+ if i < 0 {
110+ return nil
111+ }
112+ for _, chunk := range v.chunks {
113+ if i < len(chunk.rows) {
114+ return append([]renderer.Cell(nil), chunk.rows[i]...)
115+ }
116+ i -= len(chunk.rows)
117+ }
118+ return nil
119+}
A pkg/vt/history_codec.go +217 -0
@@ -0,0 +1,217 @@
1+package vt
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "fmt"
7+ "math"
8+ "unicode/utf8"
9+
10+ "github.com/bnema/vev/pkg/renderer"
11+)
12+
13+const (
14+ historyMagic = "VTH1"
15+ historyVersion = 1
16+ historyCellBytes = 41
17+ maxHistoryChunkRows = 256
18+)
19+
20+var errInvalidHistory = errors.New("invalid history payload")
21+
22+// MarshalHistory encodes a HistoryView in a deterministic, self-contained
23+// format. It preserves chunk boundaries as well as every Cell and Style field.
24+func MarshalHistory(view HistoryView) ([]byte, error) {
25+ if view.rows < 0 || view.rows != historyViewRowCount(view) || uint64(len(view.chunks)) > math.MaxUint32 {
26+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
27+ }
28+ out := make([]byte, 0, 9)
29+ out = append(out, historyMagic...)
30+ out = append(out, historyVersion)
31+ out = appendUint32(out, uint32(len(view.chunks)))
32+ for _, chunk := range view.chunks {
33+ if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) > maxHistoryChunkRows {
34+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
35+ }
36+ out = appendUint32(out, uint32(len(chunk.rows)))
37+ for _, row := range chunk.rows {
38+ if uint64(len(row)) > math.MaxUint32 {
39+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
40+ }
41+ out = appendUint32(out, uint32(len(row)))
42+ for _, cell := range row {
43+ if !validHistoryCell(cell) {
44+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
45+ }
46+ out = appendHistoryCell(out, cell)
47+ }
48+ }
49+ }
50+ return out, nil
51+}
52+
53+func historyViewRowCount(view HistoryView) int {
54+ n := 0
55+ for _, chunk := range view.chunks {
56+ if chunk != nil && len(chunk.rows) <= math.MaxInt-n {
57+ n += len(chunk.rows)
58+ continue
59+ }
60+ return -1
61+ }
62+ return n
63+}
64+
65+func appendUint32(dst []byte, v uint32) []byte {
66+ return binary.BigEndian.AppendUint32(dst, v)
67+}
68+
69+func appendHistoryCell(dst []byte, cell renderer.Cell) []byte {
70+ style := cell.Style
71+ dst = binary.BigEndian.AppendUint32(dst, uint32(cell.Rune))
72+ flags := byte(0)
73+ if cell.Continuation {
74+ flags |= 1 << 0
75+ }
76+ if style.Bold {
77+ flags |= 1 << 1
78+ }
79+ if style.Italic {
80+ flags |= 1 << 2
81+ }
82+ if style.Inverse {
83+ flags |= 1 << 3
84+ }
85+ if style.HasForegroundRGB {
86+ flags |= 1 << 4
87+ }
88+ if style.HasBackgroundRGB {
89+ flags |= 1 << 5
90+ }
91+ if style.HasUnderlineColor {
92+ flags |= 1 << 6
93+ }
94+ if style.HasUnderlineColorRGB {
95+ flags |= 1 << 7
96+ }
97+ dst = append(dst, flags)
98+ dst = binary.BigEndian.AppendUint16(dst, uint16(style.Attrs))
99+ dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Foreground)))
100+ dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Background)))
101+ dst = append(dst, style.ForegroundRGB.R, style.ForegroundRGB.G, style.ForegroundRGB.B)
102+ dst = append(dst, style.BackgroundRGB.R, style.BackgroundRGB.G, style.BackgroundRGB.B)
103+ dst = append(dst, byte(style.UnderlineStyle))
104+ dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.UnderlineColor)))
105+ return append(dst, style.UnderlineColorRGB.R, style.UnderlineColorRGB.G, style.UnderlineColorRGB.B)
106+}
107+
108+// UnmarshalHistory strictly decodes a MarshalHistory payload. It rejects
109+// malformed declarations, truncated data, and trailing bytes.
110+func UnmarshalHistory(data []byte) (HistoryView, error) {
111+ if len(data) < 9 || string(data[:4]) != historyMagic || data[4] != historyVersion {
112+ return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
113+ }
114+ p := historyParser{data: data[5:]}
115+ chunkCount, ok := p.uint32()
116+ if !ok || uint64(chunkCount) > uint64(len(p.data))/4 {
117+ return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
118+ }
119+ chunks := make([]*HistoryChunk, 0, chunkCount)
120+ rowsTotal := 0
121+ for range chunkCount {
122+ rowCount, ok := p.uint32()
123+ if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || uint64(rowCount) > uint64(len(p.data))/4 {
124+ return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
125+ }
126+ if rowsTotal > math.MaxInt-int(rowCount) {
127+ return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
128+ }
129+ rows := make([][]renderer.Cell, 0, rowCount)
130+ for range rowCount {
131+ cellCount, ok := p.uint32()
132+ if !ok || uint64(cellCount) > uint64(len(p.data))/historyCellBytes || uint64(cellCount) > uint64(math.MaxInt) {
133+ return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
134+ }
135+ row := make([]renderer.Cell, cellCount)
136+ for i := range row {
137+ cell, ok := p.cell()
138+ if !ok || !validHistoryCell(cell) {
139+ return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
140+ }
141+ row[i] = cell
142+ }
143+ rows = append(rows, row)
144+ }
145+ rowsTotal += int(rowCount)
146+ chunks = append(chunks, &HistoryChunk{rows: rows})
147+ }
148+ if len(p.data) != 0 {
149+ return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
150+ }
151+ return HistoryView{chunks: chunks, rows: rowsTotal}, nil
152+}
153+
154+type historyParser struct{ data []byte }
155+
156+func (p *historyParser) uint32() (uint32, bool) {
157+ if len(p.data) < 4 {
158+ return 0, false
159+ }
160+ v := binary.BigEndian.Uint32(p.data)
161+ p.data = p.data[4:]
162+ return v, true
163+}
164+
165+func (p *historyParser) cell() (renderer.Cell, bool) {
166+ if len(p.data) < historyCellBytes {
167+ return renderer.Cell{}, false
168+ }
169+ b := p.data[:historyCellBytes]
170+ p.data = p.data[historyCellBytes:]
171+ foreground, ok := historyInt(binary.BigEndian.Uint64(b[7:15]))
172+ if !ok {
173+ return renderer.Cell{}, false
174+ }
175+ background, ok := historyInt(binary.BigEndian.Uint64(b[15:23]))
176+ if !ok {
177+ return renderer.Cell{}, false
178+ }
179+ underlineColor, ok := historyInt(binary.BigEndian.Uint64(b[30:38]))
180+ if !ok {
181+ return renderer.Cell{}, false
182+ }
183+ flags := b[4]
184+ return renderer.Cell{
185+ Rune: rune(binary.BigEndian.Uint32(b[0:4])),
186+ Continuation: flags&1 != 0,
187+ Style: renderer.Style{
188+ Bold: flags&(1<<1) != 0,
189+ Italic: flags&(1<<2) != 0,
190+ Inverse: flags&(1<<3) != 0,
191+ Attrs: renderer.StyleAttrs(binary.BigEndian.Uint16(b[5:7])),
192+ Foreground: foreground,
193+ Background: background,
194+ HasForegroundRGB: flags&(1<<4) != 0,
195+ ForegroundRGB: renderer.RGB{R: b[23], G: b[24], B: b[25]},
196+ HasBackgroundRGB: flags&(1<<5) != 0,
197+ BackgroundRGB: renderer.RGB{R: b[26], G: b[27], B: b[28]},
198+ UnderlineStyle: renderer.UnderlineStyle(b[29]),
199+ HasUnderlineColor: flags&(1<<6) != 0,
200+ UnderlineColor: underlineColor,
201+ HasUnderlineColorRGB: flags&(1<<7) != 0,
202+ UnderlineColorRGB: renderer.RGB{R: b[38], G: b[39], B: b[40]},
203+ },
204+ }, true
205+}
206+
207+func historyInt(raw uint64) (int, bool) {
208+ v := int64(raw)
209+ if int64(int(v)) != v {
210+ return 0, false
211+ }
212+ return int(v), true
213+}
214+
215+func validHistoryCell(cell renderer.Cell) bool {
216+ return utf8.ValidRune(cell.Rune) && cell.Style.UnderlineStyle <= renderer.UnderlineDashed
217+}
@@ -0,0 +1,217 @@
1+package vt
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "fmt"
7+ "math"
8+ "unicode/utf8"
9+
10+ "github.com/bnema/vev/pkg/renderer"
11+)
12+
13+const (
14+ historyMagic = "VTH1"
15+ historyVersion = 1
16+ historyCellBytes = 41
17+ maxHistoryChunkRows = 256
18+)
19+
20+var errInvalidHistory = errors.New("invalid history payload")
21+
22+// MarshalHistory encodes a HistoryView in a deterministic, self-contained
23+// format. It preserves chunk boundaries as well as every Cell and Style field.
24+func MarshalHistory(view HistoryView) ([]byte, error) {
25+ if view.rows < 0 || view.rows != historyViewRowCount(view) || uint64(len(view.chunks)) > math.MaxUint32 {
26+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
27+ }
28+ out := make([]byte, 0, 9)
29+ out = append(out, historyMagic...)
30+ out = append(out, historyVersion)
31+ out = appendUint32(out, uint32(len(view.chunks)))
32+ for _, chunk := range view.chunks {
33+ if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) > maxHistoryChunkRows {
34+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
35+ }
36+ out = appendUint32(out, uint32(len(chunk.rows)))
37+ for _, row := range chunk.rows {
38+ if uint64(len(row)) > math.MaxUint32 {
39+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
40+ }
41+ out = appendUint32(out, uint32(len(row)))
42+ for _, cell := range row {
43+ if !validHistoryCell(cell) {
44+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
45+ }
46+ out = appendHistoryCell(out, cell)
47+ }
48+ }
49+ }
50+ return out, nil
51+}
52+
53+func historyViewRowCount(view HistoryView) int {
54+ n := 0
55+ for _, chunk := range view.chunks {
56+ if chunk != nil && len(chunk.rows) <= math.MaxInt-n {
57+ n += len(chunk.rows)
58+ continue
59+ }
60+ return -1
61+ }
62+ return n
63+}
64+
65+func appendUint32(dst []byte, v uint32) []byte {
66+ return binary.BigEndian.AppendUint32(dst, v)
67+}
68+
69+func appendHistoryCell(dst []byte, cell renderer.Cell) []byte {
70+ style := cell.Style
71+ dst = binary.BigEndian.AppendUint32(dst, uint32(cell.Rune))
72+ flags := byte(0)
73+ if cell.Continuation {
74+ flags |= 1 << 0
75+ }
76+ if style.Bold {
77+ flags |= 1 << 1
78+ }
79+ if style.Italic {
80+ flags |= 1 << 2
81+ }
82+ if style.Inverse {
83+ flags |= 1 << 3
84+ }
85+ if style.HasForegroundRGB {
86+ flags |= 1 << 4
87+ }
88+ if style.HasBackgroundRGB {
89+ flags |= 1 << 5
90+ }
91+ if style.HasUnderlineColor {
92+ flags |= 1 << 6
93+ }
94+ if style.HasUnderlineColorRGB {
95+ flags |= 1 << 7
96+ }
97+ dst = append(dst, flags)
98+ dst = binary.BigEndian.AppendUint16(dst, uint16(style.Attrs))
99+ dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Foreground)))
100+ dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Background)))
101+ dst = append(dst, style.ForegroundRGB.R, style.ForegroundRGB.G, style.ForegroundRGB.B)
102+ dst = append(dst, style.BackgroundRGB.R, style.BackgroundRGB.G, style.BackgroundRGB.B)
103+ dst = append(dst, byte(style.UnderlineStyle))
104+ dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.UnderlineColor)))
105+ return append(dst, style.UnderlineColorRGB.R, style.UnderlineColorRGB.G, style.UnderlineColorRGB.B)
106+}
107+
108+// UnmarshalHistory strictly decodes a MarshalHistory payload. It rejects
109+// malformed declarations, truncated data, and trailing bytes.
110+func UnmarshalHistory(data []byte) (HistoryView, error) {
111+ if len(data) < 9 || string(data[:4]) != historyMagic || data[4] != historyVersion {
112+ return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
113+ }
114+ p := historyParser{data: data[5:]}
115+ chunkCount, ok := p.uint32()
116+ if !ok || uint64(chunkCount) > uint64(len(p.data))/4 {
117+ return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
118+ }
119+ chunks := make([]*HistoryChunk, 0, chunkCount)
120+ rowsTotal := 0
121+ for range chunkCount {
122+ rowCount, ok := p.uint32()
123+ if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || uint64(rowCount) > uint64(len(p.data))/4 {
124+ return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
125+ }
126+ if rowsTotal > math.MaxInt-int(rowCount) {
127+ return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
128+ }
129+ rows := make([][]renderer.Cell, 0, rowCount)
130+ for range rowCount {
131+ cellCount, ok := p.uint32()
132+ if !ok || uint64(cellCount) > uint64(len(p.data))/historyCellBytes || uint64(cellCount) > uint64(math.MaxInt) {
133+ return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
134+ }
135+ row := make([]renderer.Cell, cellCount)
136+ for i := range row {
137+ cell, ok := p.cell()
138+ if !ok || !validHistoryCell(cell) {
139+ return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
140+ }
141+ row[i] = cell
142+ }
143+ rows = append(rows, row)
144+ }
145+ rowsTotal += int(rowCount)
146+ chunks = append(chunks, &HistoryChunk{rows: rows})
147+ }
148+ if len(p.data) != 0 {
149+ return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
150+ }
151+ return HistoryView{chunks: chunks, rows: rowsTotal}, nil
152+}
153+
154+type historyParser struct{ data []byte }
155+
156+func (p *historyParser) uint32() (uint32, bool) {
157+ if len(p.data) < 4 {
158+ return 0, false
159+ }
160+ v := binary.BigEndian.Uint32(p.data)
161+ p.data = p.data[4:]
162+ return v, true
163+}
164+
165+func (p *historyParser) cell() (renderer.Cell, bool) {
166+ if len(p.data) < historyCellBytes {
167+ return renderer.Cell{}, false
168+ }
169+ b := p.data[:historyCellBytes]
170+ p.data = p.data[historyCellBytes:]
171+ foreground, ok := historyInt(binary.BigEndian.Uint64(b[7:15]))
172+ if !ok {
173+ return renderer.Cell{}, false
174+ }
175+ background, ok := historyInt(binary.BigEndian.Uint64(b[15:23]))
176+ if !ok {
177+ return renderer.Cell{}, false
178+ }
179+ underlineColor, ok := historyInt(binary.BigEndian.Uint64(b[30:38]))
180+ if !ok {
181+ return renderer.Cell{}, false
182+ }
183+ flags := b[4]
184+ return renderer.Cell{
185+ Rune: rune(binary.BigEndian.Uint32(b[0:4])),
186+ Continuation: flags&1 != 0,
187+ Style: renderer.Style{
188+ Bold: flags&(1<<1) != 0,
189+ Italic: flags&(1<<2) != 0,
190+ Inverse: flags&(1<<3) != 0,
191+ Attrs: renderer.StyleAttrs(binary.BigEndian.Uint16(b[5:7])),
192+ Foreground: foreground,
193+ Background: background,
194+ HasForegroundRGB: flags&(1<<4) != 0,
195+ ForegroundRGB: renderer.RGB{R: b[23], G: b[24], B: b[25]},
196+ HasBackgroundRGB: flags&(1<<5) != 0,
197+ BackgroundRGB: renderer.RGB{R: b[26], G: b[27], B: b[28]},
198+ UnderlineStyle: renderer.UnderlineStyle(b[29]),
199+ HasUnderlineColor: flags&(1<<6) != 0,
200+ UnderlineColor: underlineColor,
201+ HasUnderlineColorRGB: flags&(1<<7) != 0,
202+ UnderlineColorRGB: renderer.RGB{R: b[38], G: b[39], B: b[40]},
203+ },
204+ }, true
205+}
206+
207+func historyInt(raw uint64) (int, bool) {
208+ v := int64(raw)
209+ if int64(int(v)) != v {
210+ return 0, false
211+ }
212+ return int(v), true
213+}
214+
215+func validHistoryCell(cell renderer.Cell) bool {
216+ return utf8.ValidRune(cell.Rune) && cell.Style.UnderlineStyle <= renderer.UnderlineDashed
217+}
M pkg/vt/screen.go +26 -7
@@ -85,6 +85,8 @@ type Screen struct {
8585 // never invoke it. Nil disables it.
8686 OnClipboard func(b64 string)
8787
88+ history *History
89+
8890 defaultFG renderer.RGB
8991 defaultBG renderer.RGB
9092 defaultColorsKnown bool
@@ -128,15 +130,23 @@ func NewScreen(width, height int) *Screen {
128130 return s
129131 }
130132
133+// NewScreenWithHistory creates a screen that records rows evicted from its
134+// primary screen into bounded immutable terminal history.
135+func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen {
136+ s := NewScreen(width, height)
137+ s.history = NewHistory(config)
138+ return s
139+}
140+
141+// History returns this screen's terminal history, or nil when history was not
142+// configured with NewScreenWithHistory.
143+func (s *Screen) History() *History { return s.history }
144+
131145 func (s *Screen) Resize(width, height int) {
132146 if width == s.Frame.Width && height == s.Frame.Height {
133147 return
134148 }
135- evict := func(row []renderer.Cell) {
136- if s.OnLineEvicted != nil {
137- s.OnLineEvicted(append([]renderer.Cell(nil), row...))
138- }
139- }
149+ evict := s.recordEvicted
140150
141151 if s.alternate != nil {
142152 var shift int
@@ -580,11 +590,20 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) {
580590 }
581591
582592 func (s *Screen) emitLineEvicted(top, n int) {
583- if s.OnLineEvicted == nil || s.alternate != nil {
593+ if s.alternate != nil {
584594 return
585595 }
586596 for y := top; y < top+n; y++ {
587- s.OnLineEvicted(append([]renderer.Cell(nil), s.Frame.Row(y)...))
597+ s.recordEvicted(s.Frame.Row(y))
598+ }
599+}
600+
601+func (s *Screen) recordEvicted(row []renderer.Cell) {
602+ if s.history != nil {
603+ s.history.Append(row)
604+ }
605+ if s.OnLineEvicted != nil {
606+ s.OnLineEvicted(append([]renderer.Cell(nil), row...))
588607 }
589608 }
590609
@@ -85,6 +85,8 @@ type Screen struct {
85 // never invoke it. Nil disables it.85 // never invoke it. Nil disables it.
86 OnClipboard func(b64 string)86 OnClipboard func(b64 string)
87 87
88+ history *History
89+
88 defaultFG renderer.RGB90 defaultFG renderer.RGB
89 defaultBG renderer.RGB91 defaultBG renderer.RGB
90 defaultColorsKnown bool92 defaultColorsKnown bool
@@ -128,15 +130,23 @@ func NewScreen(width, height int) *Screen {
128 return s130 return s
129 }131 }
130 132
133+// NewScreenWithHistory creates a screen that records rows evicted from its
134+// primary screen into bounded immutable terminal history.
135+func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen {
136+ s := NewScreen(width, height)
137+ s.history = NewHistory(config)
138+ return s
139+}
140+
141+// History returns this screen's terminal history, or nil when history was not
142+// configured with NewScreenWithHistory.
143+func (s *Screen) History() *History { return s.history }
144+
131 func (s *Screen) Resize(width, height int) {145 func (s *Screen) Resize(width, height int) {
132 if width == s.Frame.Width && height == s.Frame.Height {146 if width == s.Frame.Width && height == s.Frame.Height {
133 return147 return
134 }148 }
135- evict := func(row []renderer.Cell) {149+ evict := s.recordEvicted
136- if s.OnLineEvicted != nil {
137- s.OnLineEvicted(append([]renderer.Cell(nil), row...))
138- }
139- }
140 150
141 if s.alternate != nil {151 if s.alternate != nil {
142 var shift int152 var shift int
@@ -580,11 +590,20 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) {
580 }590 }
581 591
582 func (s *Screen) emitLineEvicted(top, n int) {592 func (s *Screen) emitLineEvicted(top, n int) {
583- if s.OnLineEvicted == nil || s.alternate != nil {593+ if s.alternate != nil {
584 return594 return
585 }595 }
586 for y := top; y < top+n; y++ {596 for y := top; y < top+n; y++ {
587- s.OnLineEvicted(append([]renderer.Cell(nil), s.Frame.Row(y)...))597+ s.recordEvicted(s.Frame.Row(y))
598+ }
599+}
600+
601+func (s *Screen) recordEvicted(row []renderer.Cell) {
602+ if s.history != nil {
603+ s.history.Append(row)
604+ }
605+ if s.OnLineEvicted != nil {
606+ s.OnLineEvicted(append([]renderer.Cell(nil), row...))
588 }607 }
589 }608 }
590 609

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