v0.3.0 99dc266

model_test.go RawHistoryFolder

1package palette
2
3import (
4	"fmt"
5	"testing"
6	"time"
7
8	renderer "github.com/bnema/vev-vt"
9	"github.com/bnema/vev/internal/domain"
10	"github.com/bnema/vev/internal/usecase/command"
11	themeui "github.com/bnema/vev/internal/usecase/theme"
12	"github.com/stretchr/testify/require"
13)
14
15func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) {
16	m := New(CommandResults([]command.Command{
17		cmd("ABC", "Alpha", "first"),
18		cmd("DEF", "Delta", "second"),
19		cmd("AXY", "Other", "third"),
20	}))
21
22	require.Equal(t, "", m.Query())
23	selected, ok := m.Selected()
24	require.True(t, ok)
25	require.Equal(t, "ABC", selectedCommandCode(t, selected))
26
27	m.Down()
28	m.Down()
29	selected, ok = m.Selected()
30	require.True(t, ok)
31	require.Equal(t, "AXY", selectedCommandCode(t, selected))
32
33	m.Insert('d')
34	require.Equal(t, "d", m.Query())
35	selected, ok = m.Selected()
36	require.True(t, ok)
37	require.Equal(t, "DEF", selectedCommandCode(t, selected), "selection clamps to only match after query changes")
38
39	m.Up()
40	selected, ok = m.Selected()
41	require.True(t, ok)
42	require.Equal(t, "DEF", selectedCommandCode(t, selected), "up at first match clamps")
43
44	m.Backspace()
45	require.Equal(t, "", m.Query())
46	require.Len(t, m.Matches(), 3)
47}
48
49func TestModelNoMatchesClearsSelection(t *testing.T) {
50	m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")}))
51	m.Insert('z')
52
53	_, ok := m.Selected()
54	require.False(t, ok)
55	require.Empty(t, m.Matches())
56}
57
58func TestModelMatchesDeepCopiesPositions(t *testing.T) {
59	m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")}))
60	m.Insert('a')
61
62	matches := m.Matches()
63	require.Len(t, matches, 1)
64	require.Equal(t, []int{0}, matches[0].Positions)
65
66	matches[0].Positions[0] = 2
67
68	fresh := m.Matches()
69	require.Equal(t, []int{0}, fresh[0].Positions)
70}
71
72func TestModelExactArgumentMatchMovesExistingFuzzyMatchToFront(t *testing.T) {
73	zzz := cmd("ZZZ", "", "ZZZ 1")
74	zzz.Arguments = command.ArgumentsRequired
75	commands := []command.Command{
76		cmd("AAA", "", "ZZZ 1"),
77		zzz,
78	}
79	want := Fuzzy(CommandResults(commands), "ZZZ 1")[1]
80	m := New(CommandResults(commands))
81
82	for _, r := range "ZZZ 1" {
83		m.Insert(r)
84	}
85
86	matches := m.Matches()
87	require.Len(t, matches, 2)
88	require.Equal(t, "ZZZ", matchCommandCode(t, matches[0]))
89	require.Equal(t, want, matches[0], "the existing fuzzy match must retain its metadata")
90	selected, ok := m.Selected()
91	require.True(t, ok)
92	require.Equal(t, "ZZZ", selectedCommandCode(t, selected))
93}
94
95func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {
96	for _, tt := range []struct {
97		name     string
98		commands func(zzz command.Command) []command.Command
99		want     Match
100		wantLen  int
101	}{
102		{
103			name: "absent fuzzy match is prepended",
104			commands: func(zzz command.Command) []command.Command {
105				return []command.Command{cmd("AAA", "", "ZZZ 1"), zzz}
106			},
107			want: newMatch(NewCommandResult(command.Command{
108				Code:      "ZZZ",
109				Desc:      "unmatched",
110				Arguments: command.ArgumentsRequired,
111			}), 0),
112			wantLen: 2,
113		},
114		{
115			name: "first fuzzy match is unchanged",
116			commands: func(zzz command.Command) []command.Command {
117				zzz.Desc = "ZZZ 1"
118				return []command.Command{zzz, cmd("ZZZZ", "", "ZZZ 1")}
119			},
120			want: func() Match {
121				zzz := cmd("ZZZ", "", "ZZZ 1")
122				zzz.Arguments = command.ArgumentsRequired
123				return Fuzzy(CommandResults([]command.Command{zzz, cmd("ZZZZ", "", "ZZZ 1")}), "ZZZ 1")[0]
124			}(),
125			wantLen: 2,
126		},
127	} {
128		t.Run(tt.name, func(t *testing.T) {
129			zzz := cmd("ZZZ", "", "unmatched")
130			zzz.Arguments = command.ArgumentsRequired
131			m := New(CommandResults(tt.commands(zzz)))
132			for _, r := range "ZZZ 1" {
133				m.Insert(r)
134			}
135
136			matches := m.Matches()
137			require.Len(t, matches, tt.wantLen)
138			require.Equal(t, tt.want, matches[0])
139		})
140	}
141}
142
143func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) {
144	m := New(CommandResults([]command.Command{
145		cmd("CPY", "Copy", "Enter copy mode"),
146		cmd("CNT", "New", "Create tab"),
147	}))
148	m.Insert('c')
149	m.Insert('y')
150	frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles()})
151
152	require.Equal(t, '>', frame.At(0, 0).Rune)
153	require.Equal(t, ' ', frame.At(1, 0).Rune)
154	require.Equal(t, 'c', frame.At(2, 0).Rune)
155	require.Equal(t, 'y', frame.At(3, 0).Rune)
156	require.True(t, frame.At(4, 0).Style.Inverse, "caret is reverse-video after query")
157
158	require.Equal(t, "CPY Enter copy mode         ", frameRow(frame, 1))
159	require.NotContains(t, frameRow(frame, 1), "Copy")
160	require.NotContains(t, frameRow(frame, 1), "—")
161	require.True(t, frame.At(0, 1).Style.Inverse, "selected line is inverse")
162	require.True(t, frame.At(5, 1).Style.Inverse, "selected line padding/text remains inverse")
163	require.True(t, frame.At(0, 1).Style.Bold, "command code C is bold")
164	require.True(t, frame.At(1, 1).Style.Bold, "command code P is bold")
165	require.True(t, frame.At(2, 1).Style.Bold, "command code Y is bold")
166	require.True(t, frame.At(4, 1).Style.Italic, "description is italic")
167}
168
169func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) {
170	m := New(CommandResults([]command.Command{cmd("CPY", "Copy", "Enter copy mode")}))
171	want := []rune("CPY Enter copy mode")
172
173	for _, cols := range []int{0, 1, 3, 4, 7} {
174		t.Run(fmt.Sprintf("cols_%d", cols), func(t *testing.T) {
175			frame := m.Render(domain.Size{Cols: cols, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
176			require.Equal(t, cols, frame.Width)
177			if cols > 0 {
178				require.Equal(t, want[:min(cols, len(want))], []rune(frameRow(frame, 1)))
179			}
180		})
181	}
182}
183
184func selectedCommandCode(t *testing.T, result Result) string {
185	t.Helper()
186	cmd, ok := result.Command()
187	require.True(t, ok, "selected result is a command")
188	return cmd.Code
189}
190
191func matchCommandCode(t *testing.T, match Match) string {
192	t.Helper()
193	return selectedCommandCode(t, match.Result)
194}
195
196func frameRow(frame renderer.Frame, y int) string {
197	row := make([]rune, frame.Width)
198	for x := range frame.Width {
199		row[x] = frame.At(x, y).Rune
200	}
201	return string(row)
202}
203
204func TestRenderUsesConfiguredStyles(t *testing.T) {
205	tests := []struct {
206		name       string
207		styles     func() RenderStyles
208		x, y       int
209		unfiltered bool
210		assert     func(t *testing.T, style renderer.Style)
211	}{
212		{
213			name: "selection style for fuzzy highlights",
214			styles: func() RenderStyles {
215				accent := renderer.DefaultStyle()
216				accent.HasBackgroundRGB = true
217				accent.BackgroundRGB = renderer.RGB{R: 1, G: 2, B: 3}
218				styles := DefaultRenderStyles()
219				styles.Selection = accent
220				return styles
221			},
222			x: 0,
223			y: 1,
224			assert: func(t *testing.T, style renderer.Style) {
225				require.True(t, style.Bold)
226				require.False(t, style.Inverse)
227				require.True(t, style.HasBackgroundRGB)
228				require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, style.BackgroundRGB)
229			},
230		},
231		{
232			name: "muted italic description style",
233			styles: func() RenderStyles {
234				muted := renderer.DefaultStyle()
235				muted.Italic = true
236				muted.HasForegroundRGB = true
237				muted.ForegroundRGB = renderer.RGB{R: 10, G: 20, B: 30}
238				styles := DefaultRenderStyles()
239				styles.Description = muted
240				return styles
241			},
242			x:          4,
243			y:          2,
244			unfiltered: true,
245			assert: func(t *testing.T, style renderer.Style) {
246				require.True(t, style.Italic)
247				require.True(t, style.HasForegroundRGB)
248				require.Equal(t, -1, style.Foreground)
249				require.Equal(t, renderer.RGB{R: 10, G: 20, B: 30}, style.ForegroundRGB)
250			},
251		},
252		{
253			name: "description italic is configurable",
254			styles: func() RenderStyles {
255				styles := DefaultRenderStyles()
256				styles.Description.Italic = false
257				styles.SelectionDescription.Italic = false
258				return styles
259			},
260			x: 4,
261			y: 1,
262			assert: func(t *testing.T, style renderer.Style) {
263				require.False(t, style.Italic)
264			},
265		},
266	}
267
268	for _, tt := range tests {
269		t.Run(tt.name, func(t *testing.T) {
270			m := New(CommandResults([]command.Command{
271				cmd("CPY", "Copy", "Enter copy mode"),
272				cmd("CNT", "New", "Create tab"),
273			}))
274			if !tt.unfiltered {
275				m.Insert('c')
276				m.Insert('y')
277			}
278			frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: tt.styles()})
279
280			tt.assert(t, frame.At(tt.x, tt.y).Style)
281		})
282	}
283}
284
285func TestPaletteDescriptionUsesSelectedRowForeground(t *testing.T) {
286	selection := renderer.DefaultStyle()
287	selection.HasForegroundRGB = true
288	selection.ForegroundRGB = renderer.RGB{R: 240, G: 241, B: 242}
289	selection.HasBackgroundRGB = true
290	selection.BackgroundRGB = renderer.RGB{R: 20, G: 21, B: 22}
291	description := renderer.DefaultStyle()
292	description.Italic = true
293	description.HasForegroundRGB = true
294	description.ForegroundRGB = renderer.RGB{R: 100, G: 101, B: 102}
295	selectionDescription := selection
296	selectionDescription.Italic = true
297	selectionDescription.ForegroundRGB = renderer.RGB{R: 220, G: 221, B: 222}
298
299	model := New(CommandResults([]command.Command{
300		cmd("ONE", "One", "first description"),
301		cmd("TWO", "Two", "second description"),
302	}))
303	frame := model.Render(domain.Size{Cols: 32, Rows: 3}, RenderOptions{Styles: RenderStyles{
304		Base: renderer.DefaultStyle(), Row: renderer.DefaultStyle(),
305		Selection: selection, Description: description, SelectionDescription: selectionDescription,
306	}})
307
308	selected := frame.At(4, 1).Style
309	require.True(t, selected.Equal(selectionDescription))
310
311	inactive := frame.At(4, 2).Style
312	require.Equal(t, description.ForegroundRGB, inactive.ForegroundRGB)
313	require.True(t, inactive.Italic)
314}
315
316func TestPaletteDescriptionWithoutSelectionStylePreservesDescriptionForeground(t *testing.T) {
317	selection := renderer.DefaultStyle()
318	selection.HasForegroundRGB = true
319	selection.ForegroundRGB = renderer.RGB{R: 240, G: 241, B: 242}
320	selection.HasBackgroundRGB = true
321	selection.BackgroundRGB = renderer.RGB{R: 20, G: 21, B: 22}
322	description := renderer.DefaultStyle()
323	description.Italic = true
324	description.HasForegroundRGB = true
325	description.ForegroundRGB = renderer.RGB{R: 100, G: 101, B: 102}
326
327	model := New(CommandResults([]command.Command{cmd("ONE", "One", "first description")}))
328	frame := model.Render(domain.Size{Cols: 32, Rows: 2}, RenderOptions{Styles: RenderStyles{
329		Base: renderer.DefaultStyle(), Row: renderer.DefaultStyle(),
330		Selection: selection, Description: description,
331	}})
332
333	selected := frame.At(4, 1).Style
334	require.Equal(t, description.ForegroundRGB, selected.ForegroundRGB)
335	require.Equal(t, selection.BackgroundRGB, selected.BackgroundRGB)
336	require.True(t, selected.Italic)
337}
338
339func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T) {
340	paletteColors := [16]renderer.RGB{}
341	paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120}
342	paletteColors[10] = paletteColors[2]
343	accentTheme := themeui.Theme{
344		Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
345		HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
346		Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10,
347	}
348	indexedTheme := accentTheme
349	indexedTheme.TrueColor = false
350	paletteOffTheme := accentTheme
351	paletteOffTheme.UsePalette = false
352	neutralTheme := paletteOffTheme
353	neutralTheme.PaletteKnown = 0
354
355	tests := []struct {
356		name   string
357		theme  themeui.Theme
358		policy domain.ThemeAccent
359	}{
360		{name: "truecolor accent", theme: accentTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
361		{name: "indexed only", theme: indexedTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
362		{name: "palette off", theme: paletteOffTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
363		{name: "forced dark", theme: themeui.BuiltinDark, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
364		{name: "forced light", theme: themeui.BuiltinLight, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
365		{name: "neutral fallback", theme: neutralTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
366	}
367
368	for _, tt := range tests {
369		t.Run(tt.name, func(t *testing.T) {
370			styles := themeui.Resolve(tt.theme, tt.policy).Styles
371			model := New(CommandResults([]command.Command{
372				cmd("ONE", "One", "first description"),
373				cmd("TWO", "Two", "second description"),
374			}))
375			model.Down()
376			frame := model.Render(domain.Size{Cols: 32, Rows: 3}, RenderOptions{Styles: RenderStyles{
377				Base: styles.PickerBase, Row: styles.SurfaceInactive,
378				Selection: styles.PickerSelection, Description: styles.PickerDescription,
379				SelectionDescription: styles.PickerSelectionMuted,
380			}})
381			inactive := frame.At(4, 1).Style
382			require.Equal(t, styles.SurfaceInactive.Background, inactive.Background)
383			require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, inactive.HasBackgroundRGB)
384			require.Equal(t, styles.SurfaceInactive.BackgroundRGB, inactive.BackgroundRGB)
385			if tt.name == "indexed only" {
386				require.Equal(t, 2, inactive.Foreground)
387				require.False(t, inactive.HasBackgroundRGB)
388			}
389
390			selected := frame.At(4, 2).Style
391			require.True(t, selected.Equal(styles.PickerSelectionMuted))
392		})
393	}
394}
395
396func TestModelCompleteSelected(t *testing.T) {
397	jrs := cmd("JRS", "Jump", "Jump to recent session")
398	jrs.Arguments = command.ArgumentsRequired
399	optional := cmd("CNS", "New session", "Create named session")
400	optional.Arguments = command.ArgumentsOptional
401	registryFirst := command.Registry()[0]
402
403	tests := []struct {
404		name       string
405		commands   []command.Command
406		registry   bool
407		query      string
408		down       int
409		want       string
410		wantChange bool
411		rendered   string
412	}{
413		{name: "nil model", want: "", wantChange: false},
414		{name: "empty model", commands: nil, want: "", wantChange: false},
415		{name: "registry first from empty query", registry: true, want: registryFirst.Code, wantChange: true, rendered: registryFirst.Code},
416		{name: "prefix match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cp", want: "CPY", wantChange: true},
417		{name: "fuzzy match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cy", want: "CPY", wantChange: true},
418		{name: "description match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "enter", want: "CPY", wantChange: true},
419		{name: "navigated selection", commands: []command.Command{cmd("AAA", "", ""), cmd("BBB", "", "")}, down: 1, want: "BBB", wantChange: true},
420		{name: "static command replaces query", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "copy", want: "CPY", wantChange: true},
421		{name: "argument command appends required space", commands: []command.Command{jrs}, query: "jrs", want: "JRS ", wantChange: true},
422		{name: "partial token preserves unicode whitespace argument bytes", commands: []command.Command{jrs}, query: "jr\u2003 \tα  β", want: "JRS α  β", wantChange: true},
423		{name: "exact code and argument is unchanged", commands: []command.Command{jrs}, query: "JRS\u2003  α  β", want: "JRS\u2003  α  β", wantChange: false},
424		{name: "leading whitespace optional argument does not duplicate token", commands: []command.Command{optional}, query: "\u2003CNS name", want: "\u2003CNS name", wantChange: false},
425		{name: "no match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "zzz", want: "zzz", wantChange: false},
426		{name: "effective override code", commands: []command.Command{{Slug: "new-tab", Code: "NT", Desc: "Create tab"}}, query: "n", want: "NT", wantChange: true},
427	}
428
429	for _, tt := range tests {
430		t.Run(tt.name, func(t *testing.T) {
431			var m *Model
432			if tt.name != "nil model" {
433				if tt.registry {
434					m = NewRegistry()
435				} else {
436					m = New(CommandResults(tt.commands))
437				}
438				for _, r := range tt.query {
439					m.Insert(r)
440				}
441				for range tt.down {
442					m.Down()
443				}
444			}
445
446			changed := m.CompleteSelected()
447			require.Equal(t, tt.wantChange, changed)
448			if m == nil {
449				require.Equal(t, tt.want, "")
450				return
451			}
452			require.Equal(t, tt.want, m.Query())
453			if tt.rendered != "" {
454				frame := m.Render(domain.Size{Cols: 32, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
455				require.Equal(t, "> "+tt.rendered, frameRow(frame, 0)[:len([]rune(tt.rendered))+2])
456			}
457		})
458	}
459}
460
461func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T) {
462	created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
463	results := []Result{
464		NewCommandResult(cmd("JRS", "Jump", "Jump to recent session")),
465		NewActiveSessionResult("work", created, "work-id"),
466		NewStoppedSessionResult("work", created),
467	}
468	m := New(results)
469	results[1] = NewActiveSessionResult("changed", created, "changed-id")
470
471	for _, r := range "switch" {
472		m.Insert(r)
473	}
474	matches := m.Matches()
475	require.Len(t, matches, 1)
476	require.Equal(t, ResultKindActiveSession, matches[0].Result.Kind())
477	matches[0].Result = NewCommandResult(cmd("BAD", "", ""))
478	require.Equal(t, ResultKindActiveSession, m.Matches()[0].Result.Kind())
479
480	selected, ok := m.Selected()
481	require.True(t, ok)
482	require.Equal(t, ResultKindActiveSession, selected.Kind())
483	id, active := selected.SessionID()
484	require.True(t, active)
485	require.Equal(t, domain.SessionID("work-id"), id)
486	require.False(t, m.CompleteSelected(), "sessions never participate in tab completion")
487	require.Equal(t, "switch", m.Query())
488	_, argument := m.ArgumentCommand()
489	require.False(t, argument)
490
491	frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
492	require.Equal(t, "Switch to session work      ", frameRow(frame, 1))
493}
494
495func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) {
496	m := New([]Result{NewStoppedSessionResult("work", time.Unix(0, 1))})
497	m.Insert('w')
498	m.Insert('k')
499
500	frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
501
502	require.Equal(t, "Resume session work         ", frameRow(frame, 1))
503	require.False(t, frame.At(14, 1).Style.Bold, "resume prefix is not highlighted")
504	require.True(t, frame.At(15, 1).Style.Bold, "first matched session rune is highlighted")
505	require.True(t, frame.At(18, 1).Style.Bold, "last matched session rune is highlighted")
506}
507
508func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) {
509	m := New([]Result{NewActiveSessionResult("work", time.Unix(0, 1), "work")})
510	frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"})
511	require.Len(t, m.Matches(), 1)
512	require.Contains(t, frameRow(frame, 1), "requested session is unavailable")
513}
514
515func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {
516	jrs := cmd("JRS", "Jump", "Jump to recent session")
517	jrs.Arguments = command.ArgumentsRequired
518	jrs.ContextHint = command.ContextHintRecentSessions
519	m := New(CommandResults([]command.Command{jrs}))
520	for _, r := range "JRS 1" {
521		m.Insert(r)
522	}
523
524	frame := m.Render(domain.Size{Cols: 28, Rows: 4}, RenderOptions{Styles: DefaultRenderStyles(), Guidance: "jump to recent session 1"})
525	if got := frameRow(frame, 1); got != "JRS jump to recent session 1" {
526		t.Fatalf("command row = %q, want contextual guidance without a feedback row", got)
527	}
528}
529
530func TestRenderStylesFillBaseRowsAndSelection(t *testing.T) {
531	m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")}))
532	base := renderer.Style{Foreground: 1, Background: 2}
533	row := renderer.Style{Foreground: 3, Background: 4}
534	selection := renderer.Style{Foreground: 5, Background: 6}
535	frame := m.Render(domain.Size{Cols: 24, Rows: 4}, RenderOptions{Styles: RenderStyles{Base: base, Row: row, Selection: selection, Description: row}})
536
537	require.True(t, frame.At(23, 3).Style.Equal(base), "unused interior keeps modal base")
538	require.True(t, frame.At(23, 0).Style.Equal(base), "input row keeps base surface")
539	require.True(t, frame.At(23, 1).Style.Equal(selection), "selected result owns active surface")
540}

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