v0.1.0 7deb448

history_codec.go RawHistoryFolder

1package vt
2
3import (
4	"encoding/binary"
5	"errors"
6	"fmt"
7	"math"
8	"unicode/utf8"
9
10	"github.com/bnema/vev/pkg/renderer"
11)
12
13const (
14	historyMagic        = "VTH1"
15	historyVersion      = 2
16	historyCellBytes    = 41
17	historyBoundBytes   = 5
18	maxHistoryChunkRows = 256
19
20	// The daemon retains 10,000 history rows. Support a 20% row margin and
21	// 160-cell rows, which leaves headroom above the representative 10k×120
22	// terminal while bounding snapshot allocation.
23	maxHistoryChunks   = 12_000
24	maxHistoryRows     = 12_000
25	maxHistoryRowCells = 160
26	maxHistoryCells    = maxHistoryRows * maxHistoryRowCells
27
28	maxHistoryDecodeStyles = maxHistoryCells
29	maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes
30)
31
32var errInvalidHistory = errors.New("invalid history payload")
33
34// MarshalHistory encodes a HistoryView in a deterministic, self-contained
35// format. It preserves chunk boundaries as well as every Cell and Style field.
36func MarshalHistory(view HistoryView) ([]byte, error) {
37	if view.rows < 0 || view.rows != historyViewRowCount(view) || uint64(len(view.chunks)) > math.MaxUint32 || uint64(len(view.chunks)) > maxHistoryChunks {
38		return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
39	}
40	stats := historyDecodeStats{}
41	for _, chunk := range view.chunks {
42		if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) > maxHistoryChunkRows ||
43			!addHistoryDecodeBudget(&stats.rows, uint64(len(chunk.rows)), maxHistoryRows) {
44			return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
45		}
46		for _, row := range chunk.rows {
47			cellCount := uint64(len(row))
48			cellBytes, ok := historyCellByteCount(cellCount)
49			if !ok ||
50				!addHistoryDecodeBudget(&stats.cells, cellCount, maxHistoryCells) ||
51				!addHistoryDecodeBudget(&stats.styles, cellCount, maxHistoryDecodeStyles) ||
52				!addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) {
53				return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
54			}
55		}
56	}
57
58	out := make([]byte, 0, 9)
59	out = append(out, historyMagic...)
60	out = append(out, historyVersion)
61	out = appendUint32(out, uint32(len(view.chunks)))
62	for _, chunk := range view.chunks {
63		if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) > maxHistoryChunkRows {
64			return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
65		}
66		out = appendUint32(out, uint32(len(chunk.rows)))
67		for i, row := range chunk.rows {
68			if uint64(len(row)) > math.MaxUint32 {
69				return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
70			}
71			bound := chunkBound(chunk, i)
72			if !validHistoryBound(bound, len(row)) {
73				return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
74			}
75			out = appendUint32(out, uint32(len(row)))
76			for _, cell := range row {
77				if !validHistoryCell(cell) {
78					return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
79				}
80				out = appendHistoryCell(out, cell)
81			}
82			out = appendHistoryBound(out, bound)
83		}
84	}
85	return out, nil
86}
87
88func historyViewRowCount(view HistoryView) int {
89	n := 0
90	for _, chunk := range view.chunks {
91		if chunk != nil && len(chunk.rows) <= math.MaxInt-n {
92			n += len(chunk.rows)
93			continue
94		}
95		return -1
96	}
97	return n
98}
99
100func appendUint32(dst []byte, v uint32) []byte {
101	return binary.BigEndian.AppendUint32(dst, v)
102}
103
104func appendHistoryCell(dst []byte, cell renderer.Cell) []byte {
105	style := cell.Style
106	dst = binary.BigEndian.AppendUint32(dst, uint32(cell.Rune))
107	flags := byte(0)
108	if cell.Continuation {
109		flags |= 1 << 0
110	}
111	if style.Bold {
112		flags |= 1 << 1
113	}
114	if style.Italic {
115		flags |= 1 << 2
116	}
117	if style.Inverse {
118		flags |= 1 << 3
119	}
120	if style.HasForegroundRGB {
121		flags |= 1 << 4
122	}
123	if style.HasBackgroundRGB {
124		flags |= 1 << 5
125	}
126	if style.HasUnderlineColor {
127		flags |= 1 << 6
128	}
129	if style.HasUnderlineColorRGB {
130		flags |= 1 << 7
131	}
132	dst = append(dst, flags)
133	dst = binary.BigEndian.AppendUint16(dst, uint16(style.Attrs))
134	dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Foreground)))
135	dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Background)))
136	dst = append(dst, style.ForegroundRGB.R, style.ForegroundRGB.G, style.ForegroundRGB.B)
137	dst = append(dst, style.BackgroundRGB.R, style.BackgroundRGB.G, style.BackgroundRGB.B)
138	dst = append(dst, byte(style.UnderlineStyle))
139	dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.UnderlineColor)))
140	return append(dst, style.UnderlineColorRGB.R, style.UnderlineColorRGB.G, style.UnderlineColorRGB.B)
141}
142
143func 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
150func 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.
159func validHistoryBound(b LineBound, cells int) bool {
160	return b.End >= 0 && b.End <= cells
161}
162
163// UnmarshalHistory strictly decodes a MarshalHistory payload. It rejects
164// malformed declarations, cells, truncated data, and trailing bytes.
165func UnmarshalHistory(data []byte) (HistoryView, error) {
166	view, _, ok := parseHistory(data, true)
167	if !ok {
168		return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
169	}
170	return view, nil
171}
172
173type historyDecodeStats struct {
174	chunks uint64
175	rows   uint64
176	cells  uint64
177	styles uint64
178	bytes  uint64
179}
180
181// preflightHistory validates a history blob without allocating decoded rows.
182// It shares parseHistory with UnmarshalHistory so both paths enforce the same
183// structural, cell, and aggregate-budget constraints.
184func preflightHistory(data []byte) (historyDecodeStats, bool) {
185	_, stats, ok := parseHistory(data, false)
186	return stats, ok
187}
188
189// parseHistory is the authoritative history payload validation path. populate
190// controls whether validated cells are retained in a HistoryView.
191func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, bool) {
192	if len(data) < 9 || string(data[:4]) != historyMagic || data[4] != historyVersion {
193		return HistoryView{}, historyDecodeStats{}, false
194	}
195
196	p := historyParser{data: data[5:]}
197	chunkCount, ok := p.uint32()
198	if !ok || uint64(chunkCount) > maxHistoryChunks || uint64(chunkCount) > uint64(len(p.data))/4 {
199		return HistoryView{}, historyDecodeStats{}, false
200	}
201	stats := historyDecodeStats{chunks: uint64(chunkCount)}
202	var chunks []*HistoryChunk
203	if populate {
204		chunks = make([]*HistoryChunk, 0, chunkCount)
205	}
206	for range chunkCount {
207		rowCount, ok := p.uint32()
208		if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows ||
209			uint64(rowCount) > uint64(len(p.data))/(4+historyBoundBytes) ||
210			!addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) {
211			return HistoryView{}, historyDecodeStats{}, false
212		}
213
214		var rows [][]renderer.Cell
215		var bounds []LineBound
216		if populate {
217			rows = make([][]renderer.Cell, 0, rowCount)
218			bounds = make([]LineBound, 0, rowCount)
219		}
220		for range rowCount {
221			cellCount, ok := p.uint32()
222			cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount))
223			if !ok || !validCellBytes ||
224				!addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryCells) ||
225				!addHistoryDecodeBudget(&stats.styles, uint64(cellCount), maxHistoryDecodeStyles) ||
226				!addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) ||
227				uint64(cellCount) > uint64(len(p.data))/historyCellBytes {
228				return HistoryView{}, historyDecodeStats{}, false
229			}
230
231			var row []renderer.Cell
232			if populate {
233				row = make([]renderer.Cell, cellCount)
234			}
235			for i := range cellCount {
236				cell, ok := p.cell()
237				if !ok || !validHistoryCell(cell) {
238					return HistoryView{}, historyDecodeStats{}, false
239				}
240				if populate {
241					row[i] = cell
242				}
243			}
244			bound, ok := p.bound()
245			if !ok || !validHistoryBound(bound, int(cellCount)) {
246				return HistoryView{}, historyDecodeStats{}, false
247			}
248			if populate {
249				rows = append(rows, row)
250				bounds = append(bounds, bound)
251			}
252		}
253		if populate {
254			chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds})
255		}
256	}
257	if len(p.data) != 0 {
258		return HistoryView{}, historyDecodeStats{}, false
259	}
260	return HistoryView{chunks: chunks, rows: int(stats.rows), cells: int(stats.cells)}, stats, true
261}
262
263func historyCellByteCount(cellCount uint64) (uint64, bool) {
264	if cellCount > math.MaxUint64/historyCellBytes {
265		return 0, false
266	}
267	return cellCount * historyCellBytes, true
268}
269
270func addHistoryDecodeBudget(total *uint64, amount, limit uint64) bool {
271	if *total > limit || amount > limit-*total {
272		return false
273	}
274	*total += amount
275	return true
276}
277
278type historyParser struct{ data []byte }
279
280func (p *historyParser) uint32() (uint32, bool) {
281	if len(p.data) < 4 {
282		return 0, false
283	}
284	v := binary.BigEndian.Uint32(p.data)
285	p.data = p.data[4:]
286	return v, true
287}
288
289func (p *historyParser) cell() (renderer.Cell, bool) {
290	if len(p.data) < historyCellBytes {
291		return renderer.Cell{}, false
292	}
293	b := p.data[:historyCellBytes]
294	p.data = p.data[historyCellBytes:]
295	foreground, ok := historyInt(binary.BigEndian.Uint64(b[7:15]))
296	if !ok {
297		return renderer.Cell{}, false
298	}
299	background, ok := historyInt(binary.BigEndian.Uint64(b[15:23]))
300	if !ok {
301		return renderer.Cell{}, false
302	}
303	underlineColor, ok := historyInt(binary.BigEndian.Uint64(b[30:38]))
304	if !ok {
305		return renderer.Cell{}, false
306	}
307	flags := b[4]
308	return renderer.Cell{
309		Rune:         rune(binary.BigEndian.Uint32(b[0:4])),
310		Continuation: flags&1 != 0,
311		Style: renderer.Style{
312			Bold:                 flags&(1<<1) != 0,
313			Italic:               flags&(1<<2) != 0,
314			Inverse:              flags&(1<<3) != 0,
315			Attrs:                renderer.StyleAttrs(binary.BigEndian.Uint16(b[5:7])),
316			Foreground:           foreground,
317			Background:           background,
318			HasForegroundRGB:     flags&(1<<4) != 0,
319			ForegroundRGB:        renderer.RGB{R: b[23], G: b[24], B: b[25]},
320			HasBackgroundRGB:     flags&(1<<5) != 0,
321			BackgroundRGB:        renderer.RGB{R: b[26], G: b[27], B: b[28]},
322			UnderlineStyle:       renderer.UnderlineStyle(b[29]),
323			HasUnderlineColor:    flags&(1<<6) != 0,
324			UnderlineColor:       underlineColor,
325			HasUnderlineColorRGB: flags&(1<<7) != 0,
326			UnderlineColorRGB:    renderer.RGB{R: b[38], G: b[39], B: b[40]},
327		},
328	}, true
329}
330
331func (p *historyParser) bound() (LineBound, bool) {
332	if len(p.data) < historyBoundBytes {
333		return LineBound{}, false
334	}
335	end := binary.BigEndian.Uint32(p.data[:4])
336	flag := p.data[4]
337	p.data = p.data[historyBoundBytes:]
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
344func historyInt(raw uint64) (int, bool) {
345	v := int64(raw)
346	if int64(int(v)) != v {
347		return 0, false
348	}
349	return int(v), true
350}
351
352func validHistoryCell(cell renderer.Cell) bool {
353	return utf8.ValidRune(cell.Rune) && cell.Style.UnderlineStyle <= renderer.UnderlineDashed
354}

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