mode.go RawHistoryFolder

1package copy
2
3import (
4	"encoding/base64"
5	"math"
6	"slices"
7	"strings"
8	"unicode"
9
10	vt "github.com/bnema/vev-vt"
11	renderer "github.com/bnema/vev-vt/ansi"
12)
13
14const OSC52MaxPayloadBytes = 75_000
15
16// Snapshot is the immutable scrollback document: sealed history plus a cloned
17// screen. screenBounds and screenRowIDs are parallel to screen rows, exactly as
18// the history view's own metadata is parallel to its chunk rows.
19type Snapshot struct {
20	history       vt.HistoryView
21	screen        renderer.Frame
22	screenBounds  []vt.LineBound
23	screenRowIDs  []vt.RowID
24	Width, Height int
25}
26
27func NewSnapshot(historySource *vt.History, screen vt.CellSource, bounds []vt.LineBound, rowIDs []vt.RowID) Snapshot {
28	return newSnapshot(historySource, screen, bounds, append([]vt.RowID(nil), rowIDs...))
29}
30
31// NewSnapshotFromScreen captures a screen while its owner holds the Screen
32// lock. Screen.RowIDs returns the one owned copy required for immutable
33// snapshot state; newSnapshot takes that slice directly without duplicating
34// it. The live screen's rowIDs cannot be transferred or shared because later
35// scroll, resize, and clear mutations update that storage.
36func NewSnapshotFromScreen(historySource *vt.History, screen *vt.Screen) Snapshot {
37	if screen == nil {
38		return NewSnapshot(historySource, renderer.Frame{}, nil, nil)
39	}
40	return newSnapshot(historySource, screen, screen.LineBounds(), screen.RowIDs())
41}
42
43func newSnapshot(historySource *vt.History, screen vt.CellSource, bounds []vt.LineBound, screenRowIDs []vt.RowID) Snapshot {
44	owned := renderer.NewFrame(screen.Columns(), screen.Rows())
45	for y := range screen.Rows() {
46		for x := range screen.Columns() {
47			owned.Set(x, y, screen.Cell(x, y))
48		}
49	}
50	var history vt.HistoryView
51	if historySource != nil {
52		history = historySource.SealAndView()
53	}
54	return Snapshot{
55		history:      history,
56		screen:       owned,
57		screenBounds: append([]vt.LineBound(nil), bounds...),
58		screenRowIDs: screenRowIDs,
59		Width:        screen.Columns(),
60		Height:       screen.Rows(),
61	}
62}
63
64// NewSnapshotFromLines builds a snapshot from raw rows and their bounds. It
65// exists for tests; production code goes through NewSnapshot.
66func NewSnapshotFromLines(rows [][]renderer.Cell, bounds []vt.LineBound, width, height int) Snapshot {
67	history := vt.NewHistory(vt.HistoryConfig{
68		MaxRows:   max(len(rows), 1),
69		MaxBytes:  math.MaxUint64, // Input is already owned and bounded by the caller.
70		ChunkRows: 256,
71	})
72	for i, row := range rows {
73		bound := vt.LineBound{End: len(row)}
74		if i < len(bounds) {
75			bound = bounds[i]
76		}
77		if err := history.Append(row, bound); err != nil {
78			panic("copy snapshot: configured history rejected supplied row")
79		}
80	}
81	return Snapshot{history: history.SealAndView(), Width: width, Height: height}
82}
83
84// NewSnapshotFromRows builds a snapshot whose rows are all hard logical lines.
85func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {
86	return NewSnapshotFromLines(rows, nil, width, height)
87}
88
89func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height }
90func (s Snapshot) Row(i int) []renderer.Cell {
91	if i < 0 {
92		return nil
93	}
94	if i < s.history.Len() {
95		return s.history.Row(i)
96	}
97	i -= s.history.Len()
98	if i >= s.screen.Height {
99		return nil
100	}
101	return s.screen.Row(i)
102}
103
104func (s Snapshot) RowWidth(y int) int {
105	if y < 0 {
106		return 0
107	}
108	if y < s.history.Len() {
109		return s.history.RowWidth(y)
110	}
111	y -= s.history.Len()
112	if y >= s.screen.Height {
113		return 0
114	}
115	return s.screen.Width
116}
117
118func (s Snapshot) Cell(x, y int) renderer.Cell {
119	if y < 0 || x < 0 {
120		return renderer.BlankCell()
121	}
122	if y < s.history.Len() {
123		return s.history.Cell(x, y)
124	}
125	y -= s.history.Len()
126	if y >= s.screen.Height || x >= s.screen.Width {
127		return renderer.BlankCell()
128	}
129	return s.screen.Cell(x, y)
130}
131
132func (s Snapshot) CopyRow(y int, dst []renderer.Cell) int {
133	if y < 0 {
134		return 0
135	}
136	if y < s.history.Len() {
137		return s.history.CopyRow(y, dst)
138	}
139	y -= s.history.Len()
140	if y >= s.screen.Height {
141		return 0
142	}
143	n := min(len(dst), s.screen.Width)
144	for x := range n {
145		dst[x] = s.screen.Cell(x, y)
146	}
147	return n
148}
149
150// Bound returns the logical extent of row i, dispatching between sealed history
151// and the live screen exactly as Row does.
152func (s Snapshot) Bound(i int) vt.LineBound {
153	if i < 0 {
154		return vt.LineBound{}
155	}
156	if i < s.history.Len() {
157		return s.history.Bound(i)
158	}
159	i -= s.history.Len()
160	if i >= s.screen.Height || i >= len(s.screenBounds) {
161		return vt.LineBound{}
162	}
163	return s.screenBounds[i]
164}
165
166// RowIDs returns an owned copy of physical row identities in document order.
167func (s Snapshot) RowIDs() []vt.RowID {
168	ids := make([]vt.RowID, s.Len())
169	for i := range ids {
170		ids[i] = s.RowID(i)
171	}
172	return ids
173}
174
175// RowID returns the identity of document row i, or zero when it is unavailable.
176func (s Snapshot) RowID(i int) vt.RowID {
177	if i < 0 {
178		return 0
179	}
180	if i < s.history.Len() {
181		return s.history.RowID(i)
182	}
183	i -= s.history.Len()
184	if i >= s.screen.Height || i >= len(s.screenRowIDs) {
185		return 0
186	}
187	return s.screenRowIDs[i]
188}
189
190// FindRowID returns the document row containing id, or -1 when it is absent.
191func (s Snapshot) FindRowID(id vt.RowID) int {
192	if id == 0 {
193		return -1
194	}
195	if row := s.history.FindRowID(id); row >= 0 {
196		return row
197	}
198	offset := s.history.Len()
199	for i, candidate := range s.screenRowIDs {
200		if i >= s.screen.Height {
201			break
202		}
203		if candidate == id {
204			return offset + i
205		}
206	}
207	return -1
208}
209
210func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) error {
211	i := 0
212	stopped := false
213	err := s.history.Range(func(r []renderer.Cell) bool {
214		if !yield(i, r) {
215			stopped = true
216			return false
217		}
218		i++
219		return true
220	})
221	if err != nil {
222		return err
223	}
224	if stopped {
225		return nil
226	}
227	for y := range s.screen.Height {
228		if !yield(i+y, s.screen.Row(y)) {
229			return nil
230		}
231	}
232	return nil
233}
234
235type SearchMatch struct {
236	Row, Start, End int
237	Text            string
238} // End is exclusive display-cell offset.
239
240type Mode struct {
241	document    *Document
242	navigator   Navigator
243	selection   Selection
244	ViewportTop int
245	SearchQuery string
246	Searches    []SearchMatch
247	SearchIndex int
248}
249
250func NewMode(doc *Document) *Mode {
251	m := &Mode{document: doc, SearchIndex: -1}
252	if doc != nil && doc.Len() > 0 {
253		m.navigator = NewNavigator(Pos{Row: doc.Len() - 1})
254		m.navigator.Set(doc, m.navigator.Pos)
255		m.ViewportTop = max(doc.Len()-max(doc.Height(), 1), 0)
256	}
257	return m
258}
259func (m *Mode) Document() *Document {
260	if m == nil {
261		return nil
262	}
263	return m.document
264}
265func (m *Mode) Cursor() Pos {
266	if m == nil {
267		return Pos{}
268	}
269	return m.navigator.Pos
270}
271func (m *Mode) Selection() Selection {
272	if m == nil {
273		return Selection{}
274	}
275	return m.selection
276}
277func (m *Mode) SetPosition(p Pos) bool {
278	if m == nil {
279		return false
280	}
281	changed := m.navigator.Set(m.document, p)
282	m.adjustViewport()
283	return changed
284}
285func (m *Mode) move(op func(*Document) bool, stream bool) bool {
286	if m == nil || m.document == nil {
287		return false
288	}
289	if stream && m.selection.Enabled && m.selection.Granularity == Line {
290		m.selection.AsCharacter()
291	}
292	changed := op(m.document)
293	if changed {
294		if m.selection.Enabled {
295			m.selection.Extend(m.navigator.Pos)
296		}
297		m.adjustViewport()
298	}
299	return changed
300}
301func (m *Mode) Left() bool         { return m.move(m.navigator.Left, true) }
302func (m *Mode) Right() bool        { return m.move(m.navigator.Right, true) }
303func (m *Mode) Up() bool           { return m.move(m.navigator.Up, false) }
304func (m *Mode) Down() bool         { return m.move(m.navigator.Down, false) }
305func (m *Mode) WordNext() bool     { return m.move(m.navigator.WordNext, true) }
306func (m *Mode) WordBackward() bool { return m.move(m.navigator.WordBackward, true) }
307func (m *Mode) WordEnd() bool      { return m.move(m.navigator.WordEnd, true) }
308func (m *Mode) MoveRows(delta int) bool {
309	if delta == 0 {
310		return false
311	}
312	changed := false
313	op := m.Up
314	if delta > 0 {
315		op = m.Down
316	}
317	for range abs(delta) {
318		if !op() {
319			break
320		}
321		changed = true
322	}
323	return changed
324}
325
326// ScrollRows moves the viewport and cursor together for mouse-wheel input.
327// Unlike keyboard navigation, reversing direction scrolls immediately rather
328// than first walking the cursor to the opposite edge of the viewport.
329func (m *Mode) ScrollRows(delta int) bool {
330	if m == nil || m.document == nil || m.document.Len() == 0 || delta == 0 {
331		return false
332	}
333	d := m.document
334	oldTop := m.ViewportTop
335	maxTop := max(d.Len()-max(d.Height(), 1), 0)
336	top := oldTop + min(max(delta, -oldTop), maxTop-oldTop)
337	row := m.navigator.Pos.Row
338	row += min(max(delta, -row), d.Len()-1-row)
339	changed := m.move(func(d *Document) bool { return m.navigator.moveVertical(d, row) }, false)
340	m.ViewportTop = top
341	return changed || oldTop != top
342}
343
344func (m *Mode) Page(pages int) bool {
345	if m == nil || m.document == nil {
346		return false
347	}
348	return m.move(func(d *Document) bool { return m.navigator.Page(d, pages*max(d.Height(), 1)) }, false)
349}
350func (m *Mode) Top() bool    { return m.move(m.navigator.Top, false) }
351func (m *Mode) Bottom() bool { return m.move(m.navigator.Bottom, false) }
352func (m *Mode) AtBottom() bool {
353	if m == nil || m.document == nil || m.document.Len() == 0 {
354		return m != nil && m.ViewportTop == 0
355	}
356	return m.navigator.Pos.Row == m.document.Len()-1 && m.ViewportTop == max(m.document.Len()-max(m.document.Height(), 1), 0)
357}
358func (m *Mode) ToggleLineSelection() {
359	if m.selection.Enabled {
360		m.selection = Selection{}
361		return
362	}
363	m.selection = NewLineSelection(m.navigator.Pos)
364}
365func (m *Mode) StartCharacterSelection(p Pos) bool {
366	if m == nil || m.document == nil {
367		return false
368	}
369	p, ok := m.document.Normalize(p)
370	if !ok {
371		return false
372	}
373	m.navigator.Set(m.document, p)
374	m.selection = Selection{Anchor: p, Active: p, Granularity: Character, Enabled: true}
375	m.adjustViewport()
376	return true
377}
378func (m *Mode) ExtendCharacterSelection(p Pos) bool {
379	if m == nil || !m.selection.Enabled {
380		return false
381	}
382	p, ok := m.document.Normalize(p)
383	if !ok {
384		return false
385	}
386	m.selection.AsCharacter()
387	m.selection.Extend(p)
388	m.navigator.Set(m.document, p)
389	m.adjustViewport()
390	return true
391}
392func (m *Mode) SelectWordAt(p Pos) bool {
393	if m == nil {
394		return false
395	}
396	s, ok := NewWordSelection(m.document, p)
397	if !ok {
398		return false
399	}
400	m.selection = s
401	m.navigator.Set(m.document, s.Active)
402	m.adjustViewport()
403	return true
404}
405func (m *Mode) ExtendWordSelection(p Pos) bool {
406	if m == nil || !m.selection.Enabled {
407		return false
408	}
409	_, end, ok := m.document.WordBounds(p)
410	if !ok {
411		return false
412	}
413	m.selection.Granularity = Word
414	m.selection.Extend(end)
415	m.navigator.Set(m.document, end)
416	m.adjustViewport()
417	return true
418}
419func (m *Mode) Search(query string) bool {
420	if m == nil {
421		return false
422	}
423	return m.SetSearchMatches(query, FindMatches(m.document, query), 0)
424}
425func (m *Mode) SetSearchMatches(query string, matches []SearchMatch, index int) bool {
426	if m == nil {
427		return false
428	}
429	m.SearchQuery = strings.TrimSpace(query)
430	if len(matches) == 0 {
431		m.Searches = nil
432		m.SearchIndex = -1
433		return false
434	}
435	m.Searches = append(m.Searches[:0], matches...)
436	m.SearchIndex = min(max(index, 0), len(matches)-1)
437	m.moveToSearchMatch(matches[m.SearchIndex])
438	return true
439}
440func (m *Mode) NextSearchMatch(delta int) bool {
441	if m == nil || len(m.Searches) == 0 || delta == 0 {
442		return false
443	}
444	m.SearchIndex = (m.SearchIndex + delta) % len(m.Searches)
445	if m.SearchIndex < 0 {
446		m.SearchIndex += len(m.Searches)
447	}
448	match := m.Searches[m.SearchIndex]
449	m.moveToSearchMatch(match)
450	return true
451}
452func (m *Mode) moveToSearchMatch(match SearchMatch) {
453	if m == nil || m.document == nil {
454		return
455	}
456	pos, ok := m.document.Normalize(Pos{Row: match.Row, Col: match.Start})
457	if !ok {
458		return
459	}
460	m.navigator.Set(m.document, pos)
461	if m.selection.Enabled {
462		m.selection.Extend(m.navigator.Pos)
463	}
464	m.adjustViewport()
465}
466func (m *Mode) SelectedText() string {
467	if m == nil {
468		return ""
469	}
470	return m.selection.Text(m.document)
471}
472func (m *Mode) adjustViewport() {
473	if m == nil || m.document == nil || m.document.Len() == 0 {
474		return
475	}
476	rows := max(m.document.Height(), 1)
477	if m.navigator.Pos.Row < m.ViewportTop {
478		m.ViewportTop = m.navigator.Pos.Row
479	}
480	if m.navigator.Pos.Row >= m.ViewportTop+rows {
481		m.ViewportTop = m.navigator.Pos.Row - rows + 1
482	}
483	m.ViewportTop = min(max(m.ViewportTop, 0), max(m.document.Len()-rows, 0))
484}
485
486func FindMatches(doc *Document, query string) []SearchMatch {
487	query = strings.TrimSpace(query)
488	if doc == nil || query == "" {
489		return nil
490	}
491	return findMatches(doc, query, doc.Snapshot().rangeRows)
492}
493
494func findMatches(doc *Document, query string, rangeRows func(func(int, []renderer.Cell) bool) error) []SearchMatch {
495	needle := lowerRunes(query)
496	matches := []SearchMatch{}
497	err := rangeRows(func(row int, cells []renderer.Cell) bool {
498		hay, indexes := searchableCells(cells)
499		text := ""
500		for start := 0; start+len(needle) <= len(hay); {
501			if !slices.Equal(hay[start:start+len(needle)], needle) {
502				start++
503				continue
504			}
505			if text == "" {
506				text = doc.LineText(row)
507			}
508			end := start + len(needle)
509			cellEnd := len(cells)
510			if end < len(indexes) {
511				cellEnd = indexes[end]
512			}
513			matches = append(matches, SearchMatch{Row: row, Start: indexes[start], End: cellEnd, Text: text})
514			start += len(needle)
515		}
516		return true
517	})
518	if err != nil {
519		// Discard partial results without taking down the daemon on corrupt history.
520		return nil
521	}
522	return matches
523}
524func searchableCells(cells []renderer.Cell) ([]rune, []int) {
525	rs := make([]rune, 0, len(cells))
526	is := make([]int, 0, len(cells))
527	for x, c := range cells {
528		if c.Continuation {
529			continue
530		}
531		r := c.Rune
532		if r == 0 {
533			r = ' '
534		}
535		rs = append(rs, unicode.ToLower(r))
536		is = append(is, x)
537	}
538	return rs, is
539}
540func lowerRunes(s string) []rune {
541	rs := []rune(s)
542	for i, r := range rs {
543		rs[i] = unicode.ToLower(r)
544	}
545	return rs
546}
547
548func (m *Mode) Render(styles ...renderer.Style) renderer.Frame {
549	if m == nil || m.document == nil {
550		return renderer.NewFrame(0, 0)
551	}
552	frame := renderer.NewFrame(m.document.Width(), m.document.Height()+1)
553	m.RenderRows(func(y int, row []renderer.Cell) { frame.WriteRow(y, 0, row) }, styles...)
554	return frame
555}
556
557// RenderRows paints viewport rows followed by the status row at Document.Height().
558// The callback borrows a reusable semantic row only until it returns. Rendering
559// directly into a compositor avoids building and then decoding a compact frame.
560func (m *Mode) RenderRows(paint func(int, []renderer.Cell), styles ...renderer.Style) {
561	if m == nil || m.document == nil {
562		return
563	}
564	m.RenderRowsRange(0, m.document.Height(), paint, styles...)
565}
566
567// RenderRowsRange paints viewport rows in [start, end), then the status row.
568// Row coordinates remain viewport-relative and the callback borrows each row.
569func (m *Mode) RenderRowsRange(start, end int, paint func(int, []renderer.Cell), styles ...renderer.Style) {
570	if m == nil || m.document == nil {
571		return
572	}
573	d := m.document
574	selectionBounds, hasSelectionBounds := m.selection.bounds(d)
575	selection, hasSelection := optionalStyle(styles, 1)
576	cursor, cursorValid := d.Normalize(m.navigator.Pos)
577	row := make([]renderer.Cell, d.Width())
578	for y := max(start, 0); y < min(end, d.Height()); y++ {
579		src := m.ViewportTop + y
580		for x := range row {
581			row[x] = renderer.BlankCell()
582		}
583		if src >= d.Len() {
584			paint(y, row)
585			continue
586		}
587		d.snapshot.CopyRow(src, row)
588		if match, ok := m.currentSearchMatchForRow(src); ok {
589			for x := max(match.Start, 0); x < min(match.End, len(row)); x++ {
590				applySelectionStyle(&row[x].Style, selection, hasSelection)
591			}
592		}
593		cursorCovered := false
594		if hasSelectionBounds {
595			if r, ok := selectionBounds.rangeForRow(d, src); ok {
596				for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ {
597					applySelectionStyle(&row[x].Style, selection, hasSelection)
598				}
599				if cursorValid && r.Row == cursor.Row && cursor.Col >= r.Start && cursor.Col <= r.End {
600					cursorCovered = true
601				}
602			}
603		}
604		if src == cursor.Row && cursorValid && !cursorCovered && len(row) > 0 {
605			cursorCol := min(cursor.Col, len(row)-1)
606			applySelectionStyle(&row[cursorCol].Style, selection, hasSelection)
607		}
608		paint(y, row)
609	}
610	status := inverseStyle()
611	if len(styles) > 0 {
612		status = styles[0]
613	}
614	drawCopyStatus(row, m, d.Len(), status)
615	paint(d.Height(), row)
616}
617func (m *Mode) currentSearchMatchForRow(row int) (SearchMatch, bool) {
618	if m == nil || m.SearchIndex < 0 || m.SearchIndex >= len(m.Searches) {
619		return SearchMatch{}, false
620	}
621	v := m.Searches[m.SearchIndex]
622	return v, v.Row == row
623}
624func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) {
625	if idx >= len(styles) {
626		return renderer.Style{}, false
627	}
628	return styles[idx], true
629}
630func applySelectionStyle(dst *renderer.Style, style renderer.Style, ok bool) {
631	if !ok || style.Equal(inverseStyle()) {
632		dst.Inverse = true
633		return
634	}
635	*dst = style
636}
637func inverseStyle() renderer.Style { s := renderer.DefaultStyle(); s.Inverse = true; return s }
638func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Style) {
639	for i := range row {
640		row[i] = renderer.Cell{Rune: ' ', Style: style}
641	}
642	text := " [SCROLL] "
643	if m.selection.Enabled {
644		text = " [SELECT] "
645	}
646	if total > 0 {
647		text += strconvItoa(m.navigator.Pos.Row+1) + "/" + strconvItoa(total) + " "
648	} else {
649		text += "0/0 "
650	}
651	if m.SearchQuery != "" {
652		if len(m.Searches) > 0 && m.SearchIndex >= 0 && m.SearchIndex < len(m.Searches) {
653			text += strconvItoa(m.SearchIndex+1) + "/" + strconvItoa(len(m.Searches)) + " "
654		}
655		text += "/" + m.SearchQuery + " "
656	}
657	for i, r := range text {
658		if i >= len(row) {
659			break
660		}
661		row[i] = renderer.Cell{Rune: r, Style: style}
662	}
663}
664
665func OSC52(text string) [][]byte {
666	if len([]byte(text)) > OSC52MaxPayloadBytes {
667		return nil
668	}
669	return [][]byte{[]byte("\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(text)) + "\x07")}
670}
671func OSC52FromBase64(b64 string) []byte {
672	decoded, err := base64.StdEncoding.DecodeString(b64)
673	if err != nil || len(decoded) > OSC52MaxPayloadBytes {
674		return nil
675	}
676	return []byte("\x1b]52;c;" + b64 + "\x07")
677}
678func strconvItoa(n int) string {
679	if n == 0 {
680		return "0"
681	}
682	var b [20]byte
683	i := len(b)
684	for n > 0 {
685		i--
686		b[i] = byte('0' + n%10)
687		n /= 10
688	}
689	return string(b[i:])
690}
691func abs(n int) int {
692	if n < 0 {
693		return -n
694	}
695	return n
696}

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