picker_interaction.go RawHistoryFolder

1package client
2
3import (
4	"fmt"
5
6	renderer "github.com/bnema/vev-vt"
7	ansirenderer "github.com/bnema/vev-vt/ansi"
8	"github.com/bnema/vev/internal/domain"
9	"github.com/bnema/vev/internal/protocol"
10	"github.com/bnema/vev/internal/usecase/picker"
11)
12
13// This file owns the client side of the picker interaction: source admission,
14// local model ownership, and typed commit/cancel. It mirrors the
15// inventoryRelay admission discipline (navigation_inventory.go) but never
16// canonicalizes line order: picker order is semantically significant. The
17// loop consumes terminal-pump records, never raw reads; commit sends a typed
18// PickerSelection on the ordered control channel, never picker bytes toward
19// the PTY.
20
21// pickerInteraction tracks one picker namespace: the admitted interaction ID
22// and the newest interaction this attachment retired. A retired interaction is
23// never resurrected by a late snapshot.
24type pickerInteraction struct {
25	open        bool
26	interaction uint64
27	retired     uint64
28	// intent is the intent the serving daemon published for this interaction.
29	// Move intents present destination rows instead of navigation rows.
30	intent protocol.PickerIntent
31	// sourceRevisions tracks the newest admitted revision per source. Every
32	// source publishes its own revision, so one source's refresh never
33	// invalidates a selection displayed from another.
34	sourceRevisions map[string]uint64
35}
36
37// setOpen starts or stops the interaction namespace. Superseding an open
38// interaction retires it too: a late snapshot for the replaced ID must not
39// take presentation back. Closing retires the namespace for good.
40func (p *pickerInteraction) setOpen(open bool, interaction uint64) {
41	if p == nil {
42		return
43	}
44	if open && interaction != p.interaction && p.open && p.interaction > p.retired {
45		p.retired = p.interaction
46	}
47	p.open = open
48	if open && interaction != p.interaction {
49		p.interaction = interaction
50		p.sourceRevisions = make(map[string]uint64)
51	}
52	if !open && interaction > p.retired {
53		p.retired = interaction
54	}
55	if !open {
56		p.intent = 0
57		p.sourceRevisions = nil
58	}
59}
60
61// setIntent records the intent published with the offer that opened the
62// interaction.
63func (p *pickerInteraction) setIntent(interaction uint64, intent protocol.PickerIntent) {
64	if p == nil || !p.open || p.interaction != interaction {
65		return
66	}
67	p.intent = intent
68}
69
70// admitSnapshot validates one source publication against the open interaction
71// and reports whether it carries a newer revision worth displaying. Retired
72// interactions, foreign interactions, and older or duplicate source revisions
73// discard.
74func (p *pickerInteraction) admitSnapshot(snapshot protocol.PickerSnapshot, wantsEvent bool) bool {
75	if p == nil {
76		return false
77	}
78	if protocol.ValidatePickerSnapshot(snapshot) != nil {
79		return false
80	}
81	if snapshot.InteractionID <= p.retired {
82		return false
83	}
84	if !p.open || snapshot.InteractionID != p.interaction {
85		return false
86	}
87	if snapshot.SourceRevision <= p.sourceRevisions[snapshot.SourceID] {
88		return false
89	}
90	if wantsEvent && p.intent == 0 {
91		// The offer carries the intent; a snapshot that arrives first is
92		// still admitted so a lost offer cannot wedge the interaction.
93	}
94	p.sourceRevisions[snapshot.SourceID] = snapshot.SourceRevision
95	return true
96}
97
98// pickerLoop owns one admitted *picker.Model plus the source publication it
99// was built from. Cursor, search, and sort are local-only presentation;
100// commit and cancel cross the wire as typed messages. The daemon revalidates
101// the committed source revision and key, so the client never keeps a second
102// copy of the row set.
103type pickerLoop struct {
104	model       *picker.Model
105	interaction uint64
106	intent      protocol.PickerIntent
107	sources     map[string]protocol.PickerSnapshot
108	order       []string
109	rows        map[string]pickerRowIdentity
110}
111
112type pickerRowIdentity struct {
113	sourceID       string
114	sourceRevision uint64
115	key            string
116}
117
118// pickerLoopFromSnapshot builds the local model from the first admitted source.
119// Source-owned opaque keys are namespaced only inside the presentation model;
120// commits translate them back to the exact source identity published on wire.
121func pickerLoopFromSnapshot(snapshot protocol.PickerSnapshot, intent protocol.PickerIntent, sort picker.SortMode) *pickerLoop {
122	snapshot = protocol.NormalizePickerSnapshot(snapshot)
123	loop := &pickerLoop{interaction: snapshot.InteractionID, intent: intent, sources: make(map[string]protocol.PickerSnapshot)}
124	loop.sources[snapshot.SourceID] = snapshot
125	loop.order = append(loop.order, snapshot.SourceID)
126	projection := pickerSnapshotProjection(snapshot, sort)
127	cursor := projection.Cursor
128	if cursor.Key != "" {
129		cursor.Key = fmt.Sprintf("%d:%s:%s", len(snapshot.SourceID), snapshot.SourceID, cursor.Key)
130	}
131	loop.rebuild(cursor, sort)
132	return loop
133}
134
135// replaceLines applies one source publication while retaining every other
136// source. Independent source revisions therefore cannot invalidate each other.
137func (l *pickerLoop) replaceLines(snapshot protocol.PickerSnapshot) {
138	snapshot = protocol.NormalizePickerSnapshot(snapshot)
139	if l == nil || l.model == nil || snapshot.InteractionID != l.interaction {
140		return
141	}
142	if _, exists := l.sources[snapshot.SourceID]; !exists {
143		l.order = append(l.order, snapshot.SourceID)
144	}
145	selectedKey := ""
146	if selected, ok := l.model.Selected(); ok {
147		selectedKey = selected.Key
148	}
149	l.sources[snapshot.SourceID] = snapshot
150	l.rebuild(protocol.PickerCursor{Key: selectedKey, Index: -1}, l.model.SortMode(), true)
151}
152
153func (l *pickerLoop) rebuild(cursor protocol.PickerCursor, sort picker.SortMode, preserveEditor ...bool) {
154	lines := make([]protocol.PickerLine, 0)
155	rows := make(map[string]pickerRowIdentity)
156	cursorKey := ""
157	for _, sourceID := range l.order {
158		snapshot, ok := l.sources[sourceID]
159		if !ok {
160			continue
161		}
162		projection := pickerSnapshotProjection(snapshot, sort)
163		for index, published := range projection.Lines {
164			line := published
165			if line.Key != "" {
166				presented := fmt.Sprintf("%d:%s:%s", len(sourceID), sourceID, line.Key)
167				rows[presented] = pickerRowIdentity{sourceID: sourceID, sourceRevision: snapshot.SourceRevision, key: line.Key}
168				if presented == cursor.Key || cursor.Key == "" && sourceID == snapshot.SourceID && index == projection.Cursor.Index {
169					cursorKey = presented
170				}
171				line.Key = presented
172			}
173			lines = append(lines, line)
174		}
175	}
176	l.rows = rows
177	modelCursor := protocol.PickerCursor{Key: cursorKey, Index: -1}
178	if len(preserveEditor) != 0 && preserveEditor[0] && l.model != nil {
179		l.model.ReplaceProjection(lines, modelCursor, sort)
180		return
181	}
182	l.model = picker.New(lines, picker.Config{Intent: l.intent, Cursor: modelCursor, Sort: sort})
183}
184
185// up moves the cursor one focusable row up.
186func (l *pickerLoop) up() {
187	if l == nil || l.model == nil {
188		return
189	}
190	l.model.Up()
191}
192
193// down moves the cursor one focusable row down.
194func (l *pickerLoop) down() {
195	if l == nil || l.model == nil {
196		return
197	}
198	l.model.Down()
199}
200
201// enterSearch begins search mode; text inserts through insert().
202func (l *pickerLoop) enterSearch() {
203	if l == nil || l.model == nil {
204		return
205	}
206	if !l.model.SearchActive() {
207		l.model.EnterSearch()
208	}
209}
210
211// insert types one rune into the active search, entering search first.
212func (l *pickerLoop) insert(r rune) {
213	if l == nil || l.model == nil {
214		return
215	}
216	l.enterSearch()
217	l.model.InsertSearch(r)
218}
219
220// backspace deletes one search rune; Escape handling distinguishes
221// search-exit from picker-close at the call site.
222func (l *pickerLoop) backspace() {
223	if l == nil || l.model == nil {
224		return
225	}
226	l.model.BackspaceSearch()
227}
228
229// escape handles one Escape press: exit search when searching, else report
230// picker-close. The boolean is true when the caller must send PickerClose.
231func (l *pickerLoop) escape() bool {
232	if l == nil || l.model == nil {
233		return true
234	}
235	if l.model.SearchActive() {
236		l.model.ExitSearch()
237		return false
238	}
239	return true
240}
241
242// toggleSort flips the local ordering between recency and grouped and reports
243// whether the display changed.
244func (l *pickerLoop) toggleSort() bool {
245	if l == nil || l.model == nil {
246		return false
247	}
248	next := picker.SortGrouped
249	if l.model.SortMode() == picker.SortGrouped {
250		next = picker.SortRecent
251	}
252	selected := protocol.PickerCursor{Index: -1}
253	if line, ok := l.model.Selected(); ok {
254		selected.Key = line.Key
255	}
256	l.rebuild(selected, next, true)
257	return true
258}
259
260func pickerSnapshotProjection(snapshot protocol.PickerSnapshot, sort picker.SortMode) protocol.PickerProjection {
261	if sort == picker.SortGrouped {
262		return snapshot.Grouped
263	}
264	return snapshot.Recent
265}
266
267// selectedAction reports the action the displayed row authorises: navigation
268// rows navigate, move rows move. The daemon publishes the bits, so the client
269// never invents authority.
270func (l *pickerLoop) selectedAction() (protocol.PickerAction, bool) {
271	if l == nil || l.model == nil {
272		return 0, false
273	}
274	line, ok := l.model.Selected()
275	if !ok {
276		return 0, false
277	}
278	switch {
279	case line.Actions&protocol.PickerCanNavigate != 0:
280		return protocol.PickerActionNavigate, true
281	case line.Actions&protocol.PickerCanMove != 0:
282		return protocol.PickerActionMove, true
283	default:
284		return 0, false
285	}
286}
287
288// killAction reports whether the cursor row authorises destruction.
289func (l *pickerLoop) killAction() bool {
290	if l == nil || l.model == nil {
291		return false
292	}
293	line, ok := l.model.Selected()
294	return ok && line.Actions&protocol.PickerCanKill != 0
295}
296
297// defaultPickerSort is the client-local initial ordering mode: sessions
298// grouped under their host, local first.
299func defaultPickerSort() picker.SortMode { return picker.SortGrouped }
300
301// pickerPreviewSize is the viewport the modal can display for one terminal: the
302// preview pane inside the shared picker geometry.
303func pickerPreviewSize(terminal domain.Size) domain.Size {
304	return picker.Size(picker.PreviewRect(terminal))
305}
306
307// emptyPickerPreview is the zero preview used until the daemon publishes one.
308func emptyPickerPreview() picker.Preview { return picker.Preview{} }
309
310// clampPreviewDimension bounds one requested viewport dimension.
311func clampPreviewDimension(value int, maxValue uint16) uint16 {
312	if value <= 0 {
313		return 1
314	}
315	if value > int(maxValue) {
316		return maxValue
317	}
318	return uint16(value)
319}
320
321// pickerRenderer encodes one picker frame to terminal bytes. It owns a
322// dedicated ANSI renderer shadow (never the daemon output shadow): the
323// client-picker frame is a local presentation overlay, not daemon output,
324// so its bytes never enter the output ACK window.
325type pickerRenderer struct {
326	renderer     *ansirenderer.Renderer
327	profile      ansirenderer.ColorProfile
328	renderStyles []picker.RenderStyles
329	size         domain.Size
330	// prevBounds is the box drawn last: when it moves or resizes, its previous
331	// cells are blanked together with the new ones, and the rest of the screen
332	// stays untouched.
333	prevBounds *domain.Rect
334	// primed records that the renderer shadow was seeded with the screen as it
335	// is, so the first box is diffed against it instead of replacing the screen.
336	primed bool
337}
338
339func newPickerRenderer(profile ansirenderer.ColorProfile) *pickerRenderer {
340	return &pickerRenderer{profile: profile}
341}
342
343// pickerColorProfile maps the terminal's detected color capability to the
344// picker renderer profile. A terminal without truecolor gets indexed colors,
345// so RGB surfaces are quantized instead of dropped (#280).
346func pickerColorProfile(trueColor bool) ansirenderer.ColorProfile {
347	if trueColor {
348		return ansirenderer.ColorProfileTrueColor
349	}
350	return ansirenderer.ColorProfileANSI256
351}
352
353// render composes the loop model into terminal bytes for one display
354// refresh. Previews arrive as source data (picker_preview.go) and are attached
355// to the model by the caller; a full redraw every refresh keeps the shadow
356// trivially consistent: the frame is small and modal, never a PTY stream.
357// render composes one floating modal over the session: the picker never takes
358// the whole screen. The box is resolved from the shared picker geometry, its
359// inner content is rendered into that rectangle, and only the box's own cells
360// are written, so the session stays visible around it until the daemon's
361// authoritative repaint after the close.
362func (r *pickerRenderer) render(loop *pickerLoop, size domain.Size, preview picker.Preview) []byte {
363	if r == nil || loop == nil || loop.model == nil {
364		return nil
365	}
366	if size.Cols <= 0 || size.Rows <= 0 {
367		return nil
368	}
369	if r.renderer == nil || r.size != size {
370		r.renderer = ansirenderer.NewWithColorProfile(ansirenderer.Capabilities{}, r.profile)
371		r.size = size
372		// Keep prevBounds across a resize. The new renderer is primed with an
373		// empty shadow below, so damaging the old rectangle explicitly clears
374		// the part of the previous modal that remains visible at the new size.
375		r.primed = false
376	}
377	// The picker is a floating box over a screen the client does not own: a
378	// fresh renderer would repaint the whole terminal from its empty shadow and
379	// wipe the session. Seed the shadow with the empty screen instead, without
380	// emitting anything, so only the box's own cells are ever written.
381	if !r.primed {
382		if prepared, err := r.renderer.Prepare(renderer.NewFrame(size.Cols, size.Rows), nil, false); err == nil {
383			prepared.Commit()
384			r.primed = true
385		}
386	}
387	presentation := picker.Modal.Resolve(size)
388	base := renderer.NewFrame(size.Cols, size.Rows)
389	border := renderer.DefaultStyle()
390	border.Attrs |= renderer.AttrDim
391	picker.Modal.CompositePresentation(base, presentation, border, renderer.DefaultStyle())
392	inner := loop.model.Render(picker.Size(presentation.Inner), preview, r.renderStyles...)
393	copyFrameRect(base, presentation.Inner, inner)
394
395	// Only the box is written: the session stays on screen around it. A box
396	// that moved (a resize, or a wider title) blanks the cells it used to own.
397	var damage []ansirenderer.Damage
398	if r.prevBounds != nil && *r.prevBounds != presentation.Bounds {
399		damage = append(damage, damageRect(*r.prevBounds))
400	}
401	damage = append(damage, damageRect(presentation.Bounds))
402	data, err := r.renderer.Draw(base, damage)
403	if err != nil {
404		return nil
405	}
406	bounds := presentation.Bounds
407	r.prevBounds = &bounds
408	return data
409}
410
411// invalidate forgets everything the renderer believes is on screen. The next
412// render re-primes an empty shadow and writes the whole box again: someone
413// else (an attachment's output or its authoritative repaint) owned the
414// terminal since the last picker frame, so a diff against the old shadow would
415// leave parts of the box missing.
416func (r *pickerRenderer) invalidate() {
417	if r == nil {
418		return
419	}
420	r.renderer = nil
421	r.primed = false
422	r.prevBounds = nil
423}
424
425// damageRect is the damage one rectangle of a composed frame produces.
426func damageRect(rect domain.Rect) ansirenderer.Damage {
427	return ansirenderer.Damage{
428		Kind: renderer.DamageText,
429		X:    rect.X, Y: rect.Y,
430		Width: rect.Width, Height: rect.Height, Count: 1,
431	}
432}
433
434// copyFrameRect blits src into dst at the target rectangle, clipped to both.
435func copyFrameRect(dst renderer.Frame, target domain.Rect, src renderer.Frame) {
436	left := max(target.X, 0)
437	top := max(target.Y, 0)
438	right := min(target.X+target.Width, dst.Width)
439	bottom := min(target.Y+target.Height, dst.Height)
440	if left >= right || top >= bottom {
441		return
442	}
443	sourceX := left - target.X
444	sourceY := top - target.Y
445	width := min(right-left, src.Width-sourceX)
446	height := min(bottom-top, src.Height-sourceY)
447	if width <= 0 || height <= 0 {
448		return
449	}
450	for y := range height {
451		for x := range width {
452			dst.Set(left+x, top+y, src.Cell(sourceX+x, sourceY+y))
453		}
454	}
455}
456
457// selection builds the typed selection for the row under the cursor at the
458// displayed source revision. Zero picker bytes travel toward the PTY: the
459// opaque key plus revision ride a PickerSelection on the ordered control
460// channel and the owning source resolves them. A kill additionally requires
461// the row to authorise destruction, so the source can verify it again.
462func (l *pickerLoop) selection(action protocol.PickerAction, causeActionID uint64) (protocol.PickerSelection, bool) {
463	if l == nil || l.model == nil {
464		return protocol.PickerSelection{}, false
465	}
466	line, ok := l.model.Selected()
467	if !ok || (action == protocol.PickerActionKill && line.Actions&protocol.PickerCanKill == 0) {
468		return protocol.PickerSelection{}, false
469	}
470	identity, ok := l.rows[line.Key]
471	if !ok {
472		return protocol.PickerSelection{}, false
473	}
474	selection := protocol.PickerSelection{
475		CauseActionID: causeActionID, InteractionID: l.interaction,
476		SourceID: identity.sourceID, SourceRevision: identity.sourceRevision,
477		Key: identity.key, Action: action,
478	}
479	if protocol.ValidatePickerSelection(selection) != nil {
480		return protocol.PickerSelection{}, false
481	}
482	return selection, true
483}
484
485// selectionKey returns the source key a selection of action would carry, or
486// the empty string when the cursor row does not admit it.
487func (l *pickerLoop) selectionKey(action protocol.PickerAction) string {
488	selection, ok := l.selection(action, 0)
489	if !ok {
490		return ""
491	}
492	return selection.Key
493}
494
495// displayedKey names the source key of the row under the cursor, whether or
496// not it admits an action.
497func (l *pickerLoop) displayedKey() (string, bool) {
498	if l == nil || l.model == nil {
499		return "", false
500	}
501	line, ok := l.model.Cursor()
502	if !ok {
503		return "", false
504	}
505	identity, ok := l.rows[line.Key]
506	return identity.key, ok
507}
508
509// sortMode reports the loop's current ordering, or ok=false without a model.
510func (l *pickerLoop) sortMode() (picker.SortMode, bool) {
511	if l == nil || l.model == nil {
512		return 0, false
513	}
514	return l.model.SortMode(), true
515}
516
517// pickerOp is the presentation decision one driver operation asks the attach
518// loop to carry out. Close wins over commit when both land in one op,
519// mirroring the overlay exit short-circuit.
520type pickerOp struct {
521	commit bool
522	kill   bool
523	close  bool
524	// exit marks the explicit exit key (Ctrl+C). It always travels with close:
525	// over a live attachment it cancels back to that attachment like any close,
526	// and only a picker with no attachment to return to ends the process on it.
527	exit bool
528}
529
530// drive applies one ui-driver operation to an open loop. Keys
531// drive cursor/search/exit: arrows and j/k move in normal mode (j/k are
532// literal in search), `/` enters search, `s` reorders locally, `x` asks to
533// destroy the cursor row, `q`/Ctrl-C/Escape close in normal mode (Escape
534// exits search first), Backspace edits the query. Text runes insert only
535// while search is active; normal-mode typing is ignored exactly like the
536// overlay.
537func (loop *pickerLoop) drive(keys []string, text string) (op pickerOp, changed bool) {
538	if loop == nil || loop.model == nil {
539		return pickerOp{}, false
540	}
541	insertLit := func(r rune) {
542		if loop.model.SearchActive() {
543			loop.insert(r)
544		}
545	}
546	for _, key := range keys {
547		active := loop.model.SearchActive()
548		beforeIndex, beforeSearch := loop.model.SelectedIndex(), active
549		beforeQuery := loop.model.Query()
550		switch key {
551		case "Up":
552			loop.up()
553		case "Down":
554			loop.down()
555		case "j":
556			if active {
557				loop.insert('j')
558			} else {
559				loop.down()
560			}
561		case "k":
562			if active {
563				loop.insert('k')
564			} else {
565				loop.up()
566			}
567		case "Enter":
568			// Commit is typed at the call site via selection; the
569			// key itself sends zero bytes toward the PTY.
570			op.commit = true
571		case "Escape":
572			if loop.escape() {
573				return pickerOp{close: true}, changed
574			}
575		case "Backspace":
576			if active {
577				loop.backspace()
578			}
579		case "/":
580			if active {
581				loop.insert('/')
582			} else {
583				loop.enterSearch()
584			}
585		case "s":
586			if active {
587				loop.insert('s')
588			} else {
589				changed = loop.toggleSort() || changed
590			}
591		case "x":
592			if active {
593				loop.insert('x')
594			} else {
595				op.kill = true
596			}
597		case "q":
598			if active {
599				loop.insert('q')
600			} else {
601				return pickerOp{close: true}, changed
602			}
603		case "Ctrl+C":
604			// The overlay exits on Ctrl-C in both modes; the byte
605			// itself never reaches the query. It is the explicit exit key.
606			return pickerOp{close: true, exit: true}, changed
607		default:
608			if len(key) == 1 {
609				insertLit(rune(key[0]))
610			}
611		}
612		if loop.model.SelectedIndex() != beforeIndex || loop.model.SearchActive() != beforeSearch || loop.model.Query() != beforeQuery {
613			changed = true
614		}
615	}
616	for _, r := range text {
617		if loop.model.SearchActive() {
618			loop.insert(r)
619			changed = true
620		}
621	}
622	return op, changed
623}
624
625// pickerInputBatch is one decoded batch: ordered input events.
626type pickerInputBatch struct {
627	events []pickerEvent
628}
629
630// pickerConsumeOutcome is one decoded operation reported by the stdin
631// pump to the attach loop. The pump decodes and identifies; the attach
632// loop is the only writer of the picker model, so the outcome carries the
633// ordered events and their interaction identity, never a mutation or a
634// message. actionID echoes the admitted record so the loop completes the
635// right ui-driver action.
636type pickerConsumeOutcome struct {
637	consumed    bool
638	actionID    uint64
639	generation  uint64
640	interaction uint64
641	events      []pickerEvent
642}
643
644// acceptOutcome reports whether this operation still belongs to the
645// interaction the attachment currently presents. Retired generations and
646// interactions drop.
647func (o pickerConsumeOutcome) acceptOutcome(interaction, generation uint64) bool {
648	return o.consumed && o.interaction != 0 && o.interaction == interaction && o.generation == generation
649}
650
651// apply applies one decoded batch in arrival order. Every event
652// is applied: a terminal read can legitimately carry several keystrokes, so
653// batch size never decides whether input is a command. Paste protection is
654// the decoder's paste state, not a heuristic here: a bracketed paste's
655// content never reaches this function.
656func (loop *pickerLoop) apply(events []pickerEvent) (op pickerOp, changed bool) {
657	if loop == nil || loop.model == nil || len(events) == 0 {
658		return pickerOp{}, false
659	}
660	for _, event := range events {
661		if event.kind == pickerEventRune {
662			if loop.model.SearchActive() {
663				loop.insert(event.r)
664				changed = true
665			}
666			continue
667		}
668		eventOp, eventChanged := loop.drive([]string{event.key}, "")
669		changed = changed || eventChanged
670		if eventOp.close {
671			return pickerOp{close: true, exit: eventOp.exit}, changed
672		}
673		op.commit = op.commit || eventOp.commit
674		op.kill = op.kill || eventOp.kill
675	}
676	return op, changed
677}

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