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// render composes the loop model into terminal bytes for one display
344// refresh. Previews arrive as source data (picker_preview.go) and are attached
345// to the model by the caller; a full redraw every refresh keeps the shadow
346// trivially consistent: the frame is small and modal, never a PTY stream.
347// render composes one floating modal over the session: the picker never takes
348// the whole screen. The box is resolved from the shared picker geometry, its
349// inner content is rendered into that rectangle, and only the box's own cells
350// are written, so the session stays visible around it until the daemon's
351// authoritative repaint after the close.
352func (r *pickerRenderer) render(loop *pickerLoop, size domain.Size, preview picker.Preview) []byte {
353	if r == nil || loop == nil || loop.model == nil {
354		return nil
355	}
356	if size.Cols <= 0 || size.Rows <= 0 {
357		return nil
358	}
359	if r.renderer == nil || r.size != size {
360		r.renderer = ansirenderer.NewWithColorProfile(ansirenderer.Capabilities{}, r.profile)
361		r.size = size
362		// Keep prevBounds across a resize. The new renderer is primed with an
363		// empty shadow below, so damaging the old rectangle explicitly clears
364		// the part of the previous modal that remains visible at the new size.
365		r.primed = false
366	}
367	// The picker is a floating box over a screen the client does not own: a
368	// fresh renderer would repaint the whole terminal from its empty shadow and
369	// wipe the session. Seed the shadow with the empty screen instead, without
370	// emitting anything, so only the box's own cells are ever written.
371	if !r.primed {
372		if prepared, err := r.renderer.Prepare(renderer.NewFrame(size.Cols, size.Rows), nil, false); err == nil {
373			prepared.Commit()
374			r.primed = true
375		}
376	}
377	presentation := picker.Modal.Resolve(size)
378	base := renderer.NewFrame(size.Cols, size.Rows)
379	border := renderer.DefaultStyle()
380	border.Attrs |= renderer.AttrDim
381	picker.Modal.CompositePresentation(base, presentation, border, renderer.DefaultStyle())
382	styles := r.renderStyles
383	if len(styles) == 0 && r.profile == ansirenderer.ColorProfileMonochrome {
384		styles = []picker.RenderStyles{picker.DefaultRenderStyles(true)}
385	}
386	inner := loop.model.Render(picker.Size(presentation.Inner), preview, styles...)
387	copyFrameRect(base, presentation.Inner, inner)
388
389	// Only the box is written: the session stays on screen around it. A box
390	// that moved (a resize, or a wider title) blanks the cells it used to own.
391	var damage []ansirenderer.Damage
392	if r.prevBounds != nil && *r.prevBounds != presentation.Bounds {
393		damage = append(damage, damageRect(*r.prevBounds))
394	}
395	damage = append(damage, damageRect(presentation.Bounds))
396	data, err := r.renderer.Draw(base, damage)
397	if err != nil {
398		return nil
399	}
400	bounds := presentation.Bounds
401	r.prevBounds = &bounds
402	return data
403}
404
405// invalidate forgets everything the renderer believes is on screen. The next
406// render re-primes an empty shadow and writes the whole box again: someone
407// else (an attachment's output or its authoritative repaint) owned the
408// terminal since the last picker frame, so a diff against the old shadow would
409// leave parts of the box missing.
410func (r *pickerRenderer) invalidate() {
411	if r == nil {
412		return
413	}
414	r.renderer = nil
415	r.primed = false
416	r.prevBounds = nil
417}
418
419// damageRect is the damage one rectangle of a composed frame produces.
420func damageRect(rect domain.Rect) ansirenderer.Damage {
421	return ansirenderer.Damage{
422		Kind: renderer.DamageText,
423		X:    rect.X, Y: rect.Y,
424		Width: rect.Width, Height: rect.Height, Count: 1,
425	}
426}
427
428// copyFrameRect blits src into dst at the target rectangle, clipped to both.
429func copyFrameRect(dst renderer.Frame, target domain.Rect, src renderer.Frame) {
430	left := max(target.X, 0)
431	top := max(target.Y, 0)
432	right := min(target.X+target.Width, dst.Width)
433	bottom := min(target.Y+target.Height, dst.Height)
434	if left >= right || top >= bottom {
435		return
436	}
437	sourceX := left - target.X
438	sourceY := top - target.Y
439	width := min(right-left, src.Width-sourceX)
440	height := min(bottom-top, src.Height-sourceY)
441	if width <= 0 || height <= 0 {
442		return
443	}
444	for y := range height {
445		for x := range width {
446			dst.Set(left+x, top+y, src.Cell(sourceX+x, sourceY+y))
447		}
448	}
449}
450
451// selection builds the typed selection for the row under the cursor at the
452// displayed source revision. Zero picker bytes travel toward the PTY: the
453// opaque key plus revision ride a PickerSelection on the ordered control
454// channel and the owning source resolves them. A kill additionally requires
455// the row to authorise destruction, so the source can verify it again.
456func (l *pickerLoop) selection(action protocol.PickerAction, causeActionID uint64) (protocol.PickerSelection, bool) {
457	if l == nil || l.model == nil {
458		return protocol.PickerSelection{}, false
459	}
460	line, ok := l.model.Selected()
461	if !ok || (action == protocol.PickerActionKill && line.Actions&protocol.PickerCanKill == 0) {
462		return protocol.PickerSelection{}, false
463	}
464	identity, ok := l.rows[line.Key]
465	if !ok {
466		return protocol.PickerSelection{}, false
467	}
468	selection := protocol.PickerSelection{
469		CauseActionID: causeActionID, InteractionID: l.interaction,
470		SourceID: identity.sourceID, SourceRevision: identity.sourceRevision,
471		Key: identity.key, Action: action,
472	}
473	if protocol.ValidatePickerSelection(selection) != nil {
474		return protocol.PickerSelection{}, false
475	}
476	return selection, true
477}
478
479// selectionKey returns the source key a selection of action would carry, or
480// the empty string when the cursor row does not admit it.
481func (l *pickerLoop) selectionKey(action protocol.PickerAction) string {
482	selection, ok := l.selection(action, 0)
483	if !ok {
484		return ""
485	}
486	return selection.Key
487}
488
489// displayedKey names the source key of the row under the cursor, whether or
490// not it admits an action.
491func (l *pickerLoop) displayedKey() (string, bool) {
492	if l == nil || l.model == nil {
493		return "", false
494	}
495	line, ok := l.model.Cursor()
496	if !ok {
497		return "", false
498	}
499	identity, ok := l.rows[line.Key]
500	return identity.key, ok
501}
502
503// sortMode reports the loop's current ordering, or ok=false without a model.
504func (l *pickerLoop) sortMode() (picker.SortMode, bool) {
505	if l == nil || l.model == nil {
506		return 0, false
507	}
508	return l.model.SortMode(), true
509}
510
511// pickerOp is the presentation decision one driver operation asks the attach
512// loop to carry out. Close wins over commit when both land in one op,
513// mirroring the overlay exit short-circuit.
514type pickerOp struct {
515	commit bool
516	kill   bool
517	close  bool
518	// exit marks the explicit exit key (Ctrl+C). It always travels with close:
519	// over a live attachment it cancels back to that attachment like any close,
520	// and only a picker with no attachment to return to ends the process on it.
521	exit bool
522}
523
524// drive applies one ui-driver operation to an open loop. Keys
525// drive cursor/search/exit: arrows and j/k move in normal mode (j/k are
526// literal in search), `/` enters search, `s` reorders locally, `x` asks to
527// destroy the cursor row, `q`/Ctrl-C/Escape close in normal mode (Escape
528// exits search first), Backspace edits the query. Text runes insert only
529// while search is active; normal-mode typing is ignored exactly like the
530// overlay.
531func (loop *pickerLoop) drive(keys []string, text string) (op pickerOp, changed bool) {
532	if loop == nil || loop.model == nil {
533		return pickerOp{}, false
534	}
535	insertLit := func(r rune) {
536		if loop.model.SearchActive() {
537			loop.insert(r)
538		}
539	}
540	for _, key := range keys {
541		active := loop.model.SearchActive()
542		beforeIndex, beforeSearch := loop.model.SelectedIndex(), active
543		beforeQuery := loop.model.Query()
544		switch key {
545		case "Up":
546			loop.up()
547		case "Down":
548			loop.down()
549		case "j":
550			if active {
551				loop.insert('j')
552			} else {
553				loop.down()
554			}
555		case "k":
556			if active {
557				loop.insert('k')
558			} else {
559				loop.up()
560			}
561		case "Enter":
562			// Commit is typed at the call site via selection; the
563			// key itself sends zero bytes toward the PTY.
564			op.commit = true
565		case "Escape":
566			if loop.escape() {
567				return pickerOp{close: true}, changed
568			}
569		case "Backspace":
570			if active {
571				loop.backspace()
572			}
573		case "/":
574			if active {
575				loop.insert('/')
576			} else {
577				loop.enterSearch()
578			}
579		case "s":
580			if active {
581				loop.insert('s')
582			} else {
583				changed = loop.toggleSort() || changed
584			}
585		case "x":
586			if active {
587				loop.insert('x')
588			} else {
589				op.kill = true
590			}
591		case "q":
592			if active {
593				loop.insert('q')
594			} else {
595				return pickerOp{close: true}, changed
596			}
597		case "Ctrl+C":
598			// The overlay exits on Ctrl-C in both modes; the byte
599			// itself never reaches the query. It is the explicit exit key.
600			return pickerOp{close: true, exit: true}, changed
601		default:
602			if len(key) == 1 {
603				insertLit(rune(key[0]))
604			}
605		}
606		if loop.model.SelectedIndex() != beforeIndex || loop.model.SearchActive() != beforeSearch || loop.model.Query() != beforeQuery {
607			changed = true
608		}
609	}
610	for _, r := range text {
611		if loop.model.SearchActive() {
612			loop.insert(r)
613			changed = true
614		}
615	}
616	return op, changed
617}
618
619// pickerInputBatch is one decoded batch: ordered input events.
620type pickerInputBatch struct {
621	events []pickerEvent
622}
623
624// pickerConsumeOutcome is one decoded operation reported by the stdin
625// pump to the attach loop. The pump decodes and identifies; the attach
626// loop is the only writer of the picker model, so the outcome carries the
627// ordered events and their interaction identity, never a mutation or a
628// message. actionID echoes the admitted record so the loop completes the
629// right ui-driver action.
630type pickerConsumeOutcome struct {
631	consumed    bool
632	actionID    uint64
633	generation  uint64
634	interaction uint64
635	events      []pickerEvent
636}
637
638// acceptOutcome reports whether this operation still belongs to the
639// interaction the attachment currently presents. Retired generations and
640// interactions drop.
641func (o pickerConsumeOutcome) acceptOutcome(interaction, generation uint64) bool {
642	return o.consumed && o.interaction != 0 && o.interaction == interaction && o.generation == generation
643}
644
645// apply applies one decoded batch in arrival order. Every event
646// is applied: a terminal read can legitimately carry several keystrokes, so
647// batch size never decides whether input is a command. Paste protection is
648// the decoder's paste state, not a heuristic here: a bracketed paste's
649// content never reaches this function.
650func (loop *pickerLoop) apply(events []pickerEvent) (op pickerOp, changed bool) {
651	if loop == nil || loop.model == nil || len(events) == 0 {
652		return pickerOp{}, false
653	}
654	for _, event := range events {
655		if event.kind == pickerEventRune {
656			if loop.model.SearchActive() {
657				loop.insert(event.r)
658				changed = true
659			}
660			continue
661		}
662		eventOp, eventChanged := loop.drive([]string{event.key}, "")
663		changed = changed || eventChanged
664		if eventOp.close {
665			return pickerOp{close: true, exit: eventOp.exit}, changed
666		}
667		op.commit = op.commit || eventOp.commit
668		op.kill = op.kill || eventOp.kill
669	}
670	return op, changed
671}

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