v0.2.0 80c61e3

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	registryFirst := command.Registry()[0]
400
401	tests := []struct {
402		name       string
403		commands   []command.Command
404		registry   bool
405		query      string
406		down       int
407		want       string
408		wantChange bool
409		rendered   string
410	}{
411		{name: "nil model", want: "", wantChange: false},
412		{name: "empty model", commands: nil, want: "", wantChange: false},
413		{name: "registry first from empty query", registry: true, want: registryFirst.Code, wantChange: true, rendered: registryFirst.Code},
414		{name: "prefix match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cp", want: "CPY", wantChange: true},
415		{name: "fuzzy match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cy", want: "CPY", wantChange: true},
416		{name: "description match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "enter", want: "CPY", wantChange: true},
417		{name: "navigated selection", commands: []command.Command{cmd("AAA", "", ""), cmd("BBB", "", "")}, down: 1, want: "BBB", wantChange: true},
418		{name: "static command replaces query", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "copy", want: "CPY", wantChange: true},
419		{name: "argument command appends required space", commands: []command.Command{jrs}, query: "jrs", want: "JRS ", wantChange: true},
420		{name: "partial token preserves unicode whitespace argument bytes", commands: []command.Command{jrs}, query: "jr\u2003 \tα  β", want: "JRS α  β", wantChange: true},
421		{name: "exact code and argument is unchanged", commands: []command.Command{jrs}, query: "JRS\u2003  α  β", want: "JRS\u2003  α  β", wantChange: false},
422		{name: "no match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "zzz", want: "zzz", wantChange: false},
423		{name: "effective override code", commands: []command.Command{{Slug: "new-tab", Code: "NT", Desc: "Create tab"}}, query: "n", want: "NT", wantChange: true},
424	}
425
426	for _, tt := range tests {
427		t.Run(tt.name, func(t *testing.T) {
428			var m *Model
429			if tt.name != "nil model" {
430				if tt.registry {
431					m = NewRegistry()
432				} else {
433					m = New(CommandResults(tt.commands))
434				}
435				for _, r := range tt.query {
436					m.Insert(r)
437				}
438				for range tt.down {
439					m.Down()
440				}
441			}
442
443			changed := m.CompleteSelected()
444			require.Equal(t, tt.wantChange, changed)
445			if m == nil {
446				require.Equal(t, tt.want, "")
447				return
448			}
449			require.Equal(t, tt.want, m.Query())
450			if tt.rendered != "" {
451				frame := m.Render(domain.Size{Cols: 32, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
452				require.Equal(t, "> "+tt.rendered, frameRow(frame, 0)[:len([]rune(tt.rendered))+2])
453			}
454		})
455	}
456}
457
458func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T) {
459	created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
460	results := []Result{
461		NewCommandResult(cmd("JRS", "Jump", "Jump to recent session")),
462		NewActiveSessionResult("work", created, "work-id"),
463		NewStoppedSessionResult("work", created),
464	}
465	m := New(results)
466	results[1] = NewActiveSessionResult("changed", created, "changed-id")
467
468	for _, r := range "switch" {
469		m.Insert(r)
470	}
471	matches := m.Matches()
472	require.Len(t, matches, 1)
473	require.Equal(t, ResultKindActiveSession, matches[0].Result.Kind())
474	matches[0].Result = NewCommandResult(cmd("BAD", "", ""))
475	require.Equal(t, ResultKindActiveSession, m.Matches()[0].Result.Kind())
476
477	selected, ok := m.Selected()
478	require.True(t, ok)
479	require.Equal(t, ResultKindActiveSession, selected.Kind())
480	id, active := selected.SessionID()
481	require.True(t, active)
482	require.Equal(t, domain.SessionID("work-id"), id)
483	require.False(t, m.CompleteSelected(), "sessions never participate in tab completion")
484	require.Equal(t, "switch", m.Query())
485	_, argument := m.ArgumentCommand()
486	require.False(t, argument)
487
488	frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
489	require.Equal(t, "Switch to session work      ", frameRow(frame, 1))
490}
491
492func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) {
493	m := New([]Result{NewStoppedSessionResult("work", time.Unix(0, 1))})
494	m.Insert('w')
495	m.Insert('k')
496
497	frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
498
499	require.Equal(t, "Resume session work         ", frameRow(frame, 1))
500	require.False(t, frame.At(14, 1).Style.Bold, "resume prefix is not highlighted")
501	require.True(t, frame.At(15, 1).Style.Bold, "first matched session rune is highlighted")
502	require.True(t, frame.At(18, 1).Style.Bold, "last matched session rune is highlighted")
503}
504
505func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) {
506	m := New([]Result{NewActiveSessionResult("work", time.Unix(0, 1), "work")})
507	frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"})
508	require.Len(t, m.Matches(), 1)
509	require.Contains(t, frameRow(frame, 1), "requested session is unavailable")
510}
511
512func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {
513	jrs := cmd("JRS", "Jump", "Jump to recent session")
514	jrs.Arguments = command.ArgumentsRequired
515	jrs.ContextHint = command.ContextHintRecentSessions
516	m := New(CommandResults([]command.Command{jrs}))
517	for _, r := range "JRS 1" {
518		m.Insert(r)
519	}
520
521	frame := m.Render(domain.Size{Cols: 28, Rows: 4}, RenderOptions{Styles: DefaultRenderStyles(), Guidance: "jump to recent session 1"})
522	if got := frameRow(frame, 1); got != "JRS jump to recent session 1" {
523		t.Fatalf("command row = %q, want contextual guidance without a feedback row", got)
524	}
525}
526
527func TestRenderStylesFillBaseRowsAndSelection(t *testing.T) {
528	m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")}))
529	base := renderer.Style{Foreground: 1, Background: 2}
530	row := renderer.Style{Foreground: 3, Background: 4}
531	selection := renderer.Style{Foreground: 5, Background: 6}
532	frame := m.Render(domain.Size{Cols: 24, Rows: 4}, RenderOptions{Styles: RenderStyles{Base: base, Row: row, Selection: selection, Description: row}})
533
534	require.True(t, frame.At(23, 3).Style.Equal(base), "unused interior keeps modal base")
535	require.True(t, frame.At(23, 0).Style.Equal(base), "input row keeps base surface")
536	require.True(t, frame.At(23, 1).Style.Equal(selection), "selected result owns active surface")
537}

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