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
504func findMatches(query string, rows textRows) []SearchMatch {
505	needle := lowerRunes(query)
506	matches := []SearchMatch{}
507	var hay []rune
508	err := rows(func(row int, runes []rune, columns []int, width int) bool {
509		hay = hay[:0]
510		for _, r := range runes {
511			hay = append(hay, unicode.ToLower(r))
512		}
513		text := ""
514		for start := 0; start+len(needle) <= len(hay); {
515			if !slices.Equal(hay[start:start+len(needle)], needle) {
516				start++
517				continue
518			}
519			if text == "" {
520				// Same text as Document.LineText, built from the runes in hand.
521				text = strings.TrimRight(string(runes), " ")
522			}
523			end := start + len(needle)
524			cellEnd := width
525			if end < len(columns) {
526				cellEnd = columns[end]
527			}
528			matches = append(matches, SearchMatch{Row: row, Start: columns[start], End: cellEnd, Text: text})
529			start += len(needle)
530		}
531		return true
532	})
533	if err != nil {
534		// Discard partial results without taking down the daemon on corrupt history.
535		return nil
536	}
537	return matches
538}
539func lowerRunes(s string) []rune {
540	rs := []rune(s)
541	for i, r := range rs {
542		rs[i] = unicode.ToLower(r)
543	}
544	return rs
545}
546
547func (m *Mode) Render(styles ...renderer.Style) renderer.Frame {
548	if m == nil || m.document == nil {
549		return renderer.NewFrame(0, 0)
550	}
551	frame := renderer.NewFrame(m.document.Width(), m.document.Height()+1)
552	m.RenderRows(func(y int, row []renderer.Cell) { frame.WriteRow(y, 0, row) }, styles...)
553	return frame
554}
555
556// RenderRows paints viewport rows followed by the status row at Document.Height().
557// The callback borrows a reusable semantic row only until it returns. Rendering
558// directly into a compositor avoids building and then decoding a compact frame.
559func (m *Mode) RenderRows(paint func(int, []renderer.Cell), styles ...renderer.Style) {
560	if m == nil || m.document == nil {
561		return
562	}
563	m.RenderRowsRange(0, m.document.Height(), paint, styles...)
564}
565
566// RenderRowsRange paints viewport rows in [start, end), then the status row.
567// Row coordinates remain viewport-relative and the callback borrows each row.
568func (m *Mode) RenderRowsRange(start, end int, paint func(int, []renderer.Cell), styles ...renderer.Style) {
569	if m == nil || m.document == nil {
570		return
571	}
572	d := m.document
573	selectionBounds, hasSelectionBounds := m.selection.bounds(d)
574	selection, hasSelection := optionalStyle(styles, 1)
575	cursor, cursorValid := d.Normalize(m.navigator.Pos)
576	buf, _ := rowPool.Get().(*[]renderer.Cell)
577	if buf == nil || cap(*buf) < d.Width() {
578		row := make([]renderer.Cell, d.Width())
579		buf = &row
580	}
581	defer rowPool.Put(buf)
582	row := (*buf)[:d.Width()]
583	for y := max(start, 0); y < min(end, d.Height()); y++ {
584		src := m.ViewportTop + y
585		for x := range row {
586			row[x] = renderer.BlankCell()
587		}
588		if src >= d.Len() {
589			paint(y, row)
590			continue
591		}
592		n := d.snapshot.CopyRow(src, row)
593		if match, ok := m.currentSearchMatchForRow(src); ok {
594			for x := max(match.Start, 0); x < min(match.End, len(row)); x++ {
595				applySelectionStyle(&row[x].Style, selection, hasSelection)
596			}
597		}
598		cursorCovered := false
599		if hasSelectionBounds {
600			bound := d.snapshot.Bound(src)
601			width := d.RowWidth(src)
602			cells, rangeCells := row[:n], row
603			if n < width {
604				cells = d.Row(src)
605				rangeCells = cells
606			}
607			limit := contentEndOf(cells, bound)
608			r, ok := selectionBounds.rangeForCells(src, rangeCells, limit)
609			if ok {
610				for x := max(r.Start, 0); x <= min(r.End, len(row)-1); x++ {
611					toggleInverse(&row[x].Style)
612				}
613				if col, ok := selectionBounds.newlineMarkerAt(src, limit, width, bound.Soft); ok && col < len(row) {
614					toggleInverse(&row[col].Style)
615					if cursorValid && cursor.Row == src && cursor.Col == col {
616						cursorCovered = true
617					}
618				}
619				if cursorValid && r.Row == cursor.Row && cursor.Col >= r.Start && cursor.Col <= r.End {
620					cursorCovered = true
621				}
622			}
623		}
624		if src == cursor.Row && cursorValid && !cursorCovered && len(row) > 0 {
625			cursorCol := min(cursor.Col, len(row)-1)
626			applySelectionStyle(&row[cursorCol].Style, selection, hasSelection)
627		}
628		paint(y, row)
629	}
630	status := inverseStyle()
631	if len(styles) > 0 {
632		status = styles[0]
633	}
634	drawCopyStatus(row, m, d.Len(), status)
635	paint(d.Height(), row)
636}
637func (m *Mode) currentSearchMatchForRow(row int) (SearchMatch, bool) {
638	if m == nil || m.SearchIndex < 0 || m.SearchIndex >= len(m.Searches) {
639		return SearchMatch{}, false
640	}
641	v := m.Searches[m.SearchIndex]
642	return v, v.Row == row
643}
644func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) {
645	if idx >= len(styles) {
646		return renderer.Style{}, false
647	}
648	return styles[idx], true
649}
650func toggleInverse(dst *renderer.Style) { dst.Inverse = !dst.Inverse }
651
652func applySelectionStyle(dst *renderer.Style, style renderer.Style, ok bool) {
653	if !ok || style.Equal(inverseStyle()) {
654		dst.Inverse = true
655		return
656	}
657	*dst = style
658}
659func inverseStyle() renderer.Style { s := renderer.DefaultStyle(); s.Inverse = true; return s }
660func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Style) {
661	for i := range row {
662		row[i] = renderer.Cell{Rune: ' ', Style: style}
663	}
664	col := 0
665	put := func(text string) {
666		for _, r := range text {
667			if col >= len(row) {
668				return
669			}
670			row[col] = renderer.Cell{Rune: r, Style: style}
671			col++
672		}
673	}
674	var digits [20]byte
675	putInt := func(n int) {
676		for _, b := range strconv.AppendInt(digits[:0], int64(n), 10) {
677			if col >= len(row) {
678				return
679			}
680			row[col] = renderer.Cell{Rune: rune(b), Style: style}
681			col++
682		}
683	}
684	if m.selection.Enabled {
685		put(" [SELECT] ")
686	} else {
687		put(" [SCROLL] ")
688	}
689	if total > 0 {
690		putInt(m.navigator.Pos.Row + 1)
691		put("/")
692		putInt(total)
693		put(" ")
694	} else {
695		put("0/0 ")
696	}
697	if m.SearchQuery != "" {
698		if len(m.Searches) > 0 && m.SearchIndex >= 0 && m.SearchIndex < len(m.Searches) {
699			putInt(m.SearchIndex + 1)
700			put("/")
701			putInt(len(m.Searches))
702			put(" ")
703		}
704		put("/")
705		put(m.SearchQuery)
706		put(" ")
707	}
708}
709
710func OSC52(text string) [][]byte {
711	if len([]byte(text)) > OSC52MaxPayloadBytes {
712		return nil
713	}
714	return [][]byte{[]byte("\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(text)) + "\x07")}
715}
716func OSC52FromBase64(b64 string) []byte {
717	decoded, err := base64.StdEncoding.DecodeString(b64)
718	if err != nil || len(decoded) > OSC52MaxPayloadBytes {
719		return nil
720	}
721	return []byte("\x1b]52;c;" + b64 + "\x07")
722}
723func abs(n int) int {
724	if n < 0 {
725		return -n
726	}
727	return n
728}

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