mode.go RawHistoryFolder

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

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