model_test.go RawHistoryFolder

1package picker
2
3import (
4	"strings"
5	"testing"
6
7	renderer "github.com/bnema/vev-vt"
8	"github.com/bnema/vev/internal/domain"
9	"github.com/bnema/vev/internal/protocol"
10	"github.com/bnema/vev/internal/usecase/ui"
11	"github.com/stretchr/testify/require"
12)
13
14// line builds one selectable session line for the shared fixtures.
15func line(key, label string, actions protocol.PickerLineActions) protocol.PickerLine {
16	return protocol.PickerLine{Key: key, Kind: protocol.PickerLineSession, Label: label, Focusable: true, Actions: actions}
17}
18
19func navLine(key, label string) protocol.PickerLine {
20	return line(key, label, protocol.PickerCanNavigate)
21}
22
23func section(label string) protocol.PickerLine {
24	return protocol.PickerLine{Kind: protocol.PickerLineSection, Label: label, Dim: true}
25}
26
27func tabLine(key, label string, actions protocol.PickerLineActions) protocol.PickerLine {
28	return protocol.PickerLine{Key: key, Kind: protocol.PickerLineTab, Label: label, Focusable: true, Actions: actions}
29}
30
31// inspectionLine builds one row the source keeps reachable without authorising
32// any action on it.
33func inspectionLine(key, label string) protocol.PickerLine {
34	return protocol.PickerLine{Key: key, Kind: protocol.PickerLineHost, Label: label, Dim: true, Focusable: true}
35}
36
37func TestNewOrdersLinesAndSelectsTheCursorKey(t *testing.T) {
38	m := New([]protocol.PickerLine{
39		section("LOCAL"),
40		navLine("a/one", "one"),
41		tabLine("a/one#t2", "logs", protocol.PickerCanNavigate),
42		tabLine("a/one#t1", "shell", protocol.PickerCanNavigate),
43	}, Config{Intent: protocol.PickerIntentNavigation, Cursor: protocol.PickerCursor{Key: "a/one#t1", Index: 3}})
44
45	selected, ok := m.Selected()
46	require.True(t, ok)
47	require.Equal(t, "a/one#t1", selected.Key, "the published cursor key wins over the index hint")
48}
49
50func TestNewSelectsTheNearestFocusableRowForAnUnknownCursor(t *testing.T) {
51	m := New([]protocol.PickerLine{
52		section("LOCAL"),
53		navLine("a/one", "one"),
54		tabLine("a/one#t1", "shell", protocol.PickerCanNavigate),
55	}, Config{Intent: protocol.PickerIntentNavigation, Cursor: protocol.PickerCursor{Key: "gone", Index: 2}})
56
57	selected, ok := m.Selected()
58	require.True(t, ok)
59	require.Equal(t, "a/one#t1", selected.Key)
60}
61
62func TestUpDownSkipsSectionsAndUnselectableRows(t *testing.T) {
63	m := New([]protocol.PickerLine{
64		navLine("a/one", "one"),
65		// A host status row is focusable but carries no action.
66		inspectionLine("b/host", "example.test"),
67		navLine("c/three", "three"),
68	}, Config{Intent: protocol.PickerIntentNavigation})
69
70	m.SelectNearestRow(0)
71	m.Down()
72	selected, ok := m.Selected()
73	require.False(t, ok, "a row without actions is never selectable")
74	m.Down()
75	selected, ok = m.Selected()
76	require.True(t, ok)
77	require.Equal(t, "c/three", selected.Key)
78	m.Up()
79	cursor, ok := m.Cursor()
80	require.True(t, ok, "the cursor parks on the host row without activating it")
81	require.Equal(t, "b/host", cursor.Key)
82	m.Up()
83	selected, ok = m.Selected()
84	require.True(t, ok)
85	require.Equal(t, "a/one", selected.Key)
86}
87
88func TestSortGroupedPullsNamedLinesAheadOfEphemeralOnes(t *testing.T) {
89	lines := []protocol.PickerLine{
90		{Key: "e/eph", Kind: protocol.PickerLineSession, Label: "eph", Ephemeral: true, Focusable: true, Actions: protocol.PickerCanNavigate},
91		navLine("a/named", "named"),
92		{Key: "e/eph2", Kind: protocol.PickerLineSession, Label: "eph2", Ephemeral: true, Focusable: true, Actions: protocol.PickerCanNavigate},
93	}
94	recent := New(lines, Config{Intent: protocol.PickerIntentNavigation})
95	first, ok := recent.Cursor()
96	require.True(t, ok)
97	require.Equal(t, "e/eph", first.Key, "recency keeps the published order")
98
99	grouped := New(lines, Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped})
100	first, ok = grouped.Cursor()
101	require.True(t, ok)
102	require.Equal(t, "a/named", first.Key, "grouped moves named sessions ahead")
103}
104
105func TestSetSortKeepsTheSelectedKey(t *testing.T) {
106	m := New([]protocol.PickerLine{
107		{Key: "e/eph", Kind: protocol.PickerLineSession, Label: "eph", Ephemeral: true, Focusable: true, Actions: protocol.PickerCanNavigate},
108		navLine("a/named", "named"),
109		navLine("b/other", "other"),
110	}, Config{Intent: protocol.PickerIntentNavigation, Cursor: protocol.PickerCursor{Key: "b/other", Index: 2}})
111
112	m.SetSort(SortGrouped)
113	selected, ok := m.Selected()
114	require.True(t, ok)
115	require.Equal(t, "b/other", selected.Key)
116	require.Equal(t, SortGrouped, m.SortMode())
117
118	m.SetSort(SortRecent)
119	selected, ok = m.Selected()
120	require.True(t, ok)
121	require.Equal(t, "b/other", selected.Key)
122}
123
124func TestReplaceLinesKeepsSearchAndCursor(t *testing.T) {
125	m := New([]protocol.PickerLine{
126		navLine("a/one", "one"),
127		navLine("b/two", "two"),
128	}, Config{Intent: protocol.PickerIntentNavigation, Cursor: protocol.PickerCursor{Key: "b/two", Index: 1}})
129	m.EnterSearch()
130	m.InsertSearch('t')
131
132	m.ReplaceLines([]protocol.PickerLine{
133		navLine("a/one", "one"),
134		navLine("b/two", "two"),
135		navLine("c/three", "three"),
136	}, protocol.PickerCursor{})
137
138	require.True(t, m.SearchActive())
139	require.Equal(t, "t", m.Query())
140	selected, ok := m.Selected()
141	require.True(t, ok)
142	require.Equal(t, "b/two", selected.Key, "the cursor survives a background refresh by key")
143}
144
145func TestSelectedHonoursSearchVisibility(t *testing.T) {
146	m := New([]protocol.PickerLine{
147		navLine("a/one", "one"),
148		navLine("b/two", "two"),
149	}, Config{Intent: protocol.PickerIntentNavigation, Cursor: protocol.PickerCursor{Key: "a/one"}})
150	m.EnterSearch()
151	m.InsertSearch('z')
152
153	_, ok := m.Selected()
154	require.False(t, ok, "a cursor hidden by the search is never a commit target")
155	require.Equal(t, -1, m.SelectedIndex(), "a query without matches leaves no visible cursor")
156}
157
158func TestRowsTheSourceSkippedAreNeverCursorDestinations(t *testing.T) {
159	m := New([]protocol.PickerLine{
160		// A session header the source did not authorise: rendered, skipped.
161		{Key: "a/one", Kind: protocol.PickerLineSession, Label: "one"},
162		tabLine("a/one#t1", "shell", protocol.PickerCanNavigate),
163		inspectionLine("b/host", "example.test"),
164	}, Config{Intent: protocol.PickerIntentNavigation, Cursor: protocol.PickerCursor{Key: "a/one", Index: 0}})
165
166	require.Equal(t, "a/one#t1", mustSelectedKey(t, m), "an unauthorised header is not a destination")
167	m.Up()
168	require.Equal(t, "a/one#t1", mustSelectedKey(t, m), "navigation never rests on the header")
169	m.Down()
170	require.Equal(t, "b/host", cursorKey(t, m), "an inspection row stays reachable")
171	_, ok := m.Selected()
172	require.False(t, ok, "an inspection row never commits")
173	m.Down()
174	require.Equal(t, "b/host", cursorKey(t, m), "the last eligible row holds the cursor")
175}
176
177func TestSearchWithoutMatchesClearsTheCursorAndMovementFollows(t *testing.T) {
178	m := New([]protocol.PickerLine{
179		navLine("a/one", "one"),
180		navLine("b/two", "two"),
181		navLine("c/three", "three"),
182	}, Config{Intent: protocol.PickerIntentNavigation})
183	m.EnterSearch()
184	m.InsertSearch('t')
185	require.Equal(t, 2, m.MatchCount())
186	m.Down()
187	require.Equal(t, "c/three", mustSelectedKey(t, m), "search movement only visits matching rows")
188	m.InsertSearch('z')
189	require.Equal(t, 0, m.MatchCount())
190	require.Equal(t, -1, m.SelectedIndex())
191	m.Down()
192	require.Equal(t, -1, m.SelectedIndex(), "movement cannot resurrect a hidden cursor")
193	m.BackspaceSearch()
194	require.Equal(t, 2, m.MatchCount())
195	require.Equal(t, "b/two", mustSelectedKey(t, m), "editing the query re-places the cursor on a shown row")
196	m.ClearSearch()
197	require.Equal(t, 3, m.MatchCount(), "an empty query shows every eligible row again")
198	require.Equal(t, "b/two", mustSelectedKey(t, m))
199}
200
201// mustSelectedKey reports the committed key, failing when the cursor holds no
202// committable row.
203func mustSelectedKey(t *testing.T, m *Model) string {
204	t.Helper()
205	selected, ok := m.Selected()
206	require.True(t, ok, "the cursor must hold a committable row")
207	return selected.Key
208}
209
210// cursorKey reports the raw cursor row key independently of committability.
211func cursorKey(t *testing.T, m *Model) string {
212	t.Helper()
213	line, ok := m.Cursor()
214	require.True(t, ok)
215	return line.Key
216}
217
218func TestSelectNearestRowSnapsForwardThenBackward(t *testing.T) {
219	m := New([]protocol.PickerLine{
220		navLine("a/one", "one"),
221		section("LOCAL"),
222		navLine("b/two", "two"),
223	}, Config{Intent: protocol.PickerIntentNavigation})
224
225	m.SelectNearestRow(1)
226	selected, ok := m.Selected()
227	require.True(t, ok)
228	require.Equal(t, "b/two", selected.Key)
229	m.SelectNearestRow(40)
230	selected, ok = m.Selected()
231	require.True(t, ok)
232	require.Equal(t, "b/two", selected.Key)
233	m.SelectNearestRow(-3)
234	selected, ok = m.Selected()
235	require.True(t, ok)
236	require.Equal(t, "a/one", selected.Key)
237}
238
239func TestCloneIsIndependent(t *testing.T) {
240	m := New([]protocol.PickerLine{navLine("a/one", "one"), navLine("b/two", "two")}, Config{Intent: protocol.PickerIntentNavigation})
241	clone := m.Clone()
242	clone.Down()
243	original, ok := m.Selected()
244	require.True(t, ok)
245	cloneSelected, ok := clone.Selected()
246	require.True(t, ok)
247	require.Equal(t, "a/one", original.Key)
248	require.Equal(t, "b/two", cloneSelected.Key)
249}
250
251func TestIntentIsReported(t *testing.T) {
252	m := New([]protocol.PickerLine{tabLine("a/one#t1", "shell", protocol.PickerCanMove)}, Config{Intent: protocol.PickerIntentMovePane})
253	require.Equal(t, protocol.PickerIntentMovePane, m.Intent())
254	require.Equal(t, " Sessions · recent ", SortRecent.Title())
255	require.Equal(t, " Sessions · grouped ", SortGrouped.Title())
256}
257
258func TestChooseLayoutResponsiveBoundaries(t *testing.T) {
259	tests := []struct {
260		name    string
261		size    domain.Size
262		mode    LayoutMode
263		list    domain.Rect
264		preview domain.Rect
265	}{
266		{name: "too small", size: domain.Size{Cols: 0, Rows: 0}, mode: LayoutListOnly},
267		{name: "list only", size: domain.Size{Cols: 40, Rows: 10}, mode: LayoutListOnly, list: domain.Rect{Width: 40, Height: 10}},
268		{name: "stacked", size: domain.Size{Cols: 40, Rows: 20}, mode: LayoutStacked, list: domain.Rect{Width: 40, Height: 8}, preview: domain.Rect{Y: 9, Width: 40, Height: 11}},
269		{name: "horizontal", size: domain.Size{Cols: 120, Rows: 20}, mode: LayoutHorizontal, list: domain.Rect{Width: 44, Height: 20}, preview: domain.Rect{X: 45, Width: 75, Height: 20}},
270	}
271	for _, tt := range tests {
272		t.Run(tt.name, func(t *testing.T) {
273			layout := ChooseLayout(tt.size)
274			require.Equal(t, tt.mode, layout.Mode)
275			require.Equal(t, tt.list, layout.List)
276			require.Equal(t, tt.preview, layout.Preview)
277		})
278	}
279}
280
281func TestChooseGeometryReservesTheStatusRow(t *testing.T) {
282	geometry := ChooseGeometry(domain.Size{Cols: 80, Rows: 24})
283	require.Equal(t, domain.Rect{Width: 80, Height: 23}, geometry.Content)
284	require.Equal(t, domain.Rect{Y: 23, Width: 80, Height: 1}, geometry.Status)
285}
286
287func TestRenderDrawsProblemDotsOnly(t *testing.T) {
288	m := New([]protocol.PickerLine{
289		{Kind: protocol.PickerLineSection, Label: "host-a", Status: protocol.PickerLineStatusDown},
290		{
291			Key: "a/broken", Kind: protocol.PickerLineSession, Label: "broken", Focusable: true, Actions: protocol.PickerCanNavigate,
292			Status: protocol.PickerLineStatusError, Detail: "2",
293		},
294		{
295			Key: "a/live", Kind: protocol.PickerLineSession, Label: "live", Focusable: true, Actions: protocol.PickerCanNavigate,
296			Status: protocol.PickerLineStatusUp, Detail: "2",
297		},
298		{
299			Key: "b/old", Kind: protocol.PickerLineSession, Label: "old", Stopped: true,
300			Status: protocol.PickerLineStatusStopped, Detail: "stopped", Focusable: true, Actions: protocol.PickerCanNavigate,
301		},
302	}, Config{Intent: protocol.PickerIntentNavigation})
303
304	frame := m.Render(domain.Size{Cols: 60, Rows: 8}, Preview{})
305	require.Equal(t, 60, frame.Width)
306	require.Equal(t, 8, frame.Height)
307	for row, want := range map[int]int{0: 167, 1: 167} {
308		text := []rune(strings.TrimRight(rowText(frame.Row(row)), " "))
309		require.Equal(t, '●', text[len(text)-1], "row %d has a problem dot", row)
310		dot := frame.At(len(text)-1, row).Style
311		require.Equal(t, want, dot.Foreground, "row %d dot color", row)
312		require.False(t, dot.Inverse, "row %d dot is never inverted, even when selected", row)
313	}
314	require.True(t, frame.At(0, 1).Style.Inverse, "the broken session is the selected row")
315	for _, row := range []int{2, 3} {
316		require.NotContains(t, rowText(frame.Row(row)), "●", "healthy and stopped rows carry no dot")
317	}
318}
319
320func TestRenderShowsNoDaemonBadgeAndCreateHint(t *testing.T) {
321	m := New([]protocol.PickerLine{{
322		Key: "h/remote", Kind: protocol.PickerLineHost, Label: "remote", Detail: "0 sessions",
323		Status: protocol.PickerLineStatusNoDaemon, StatusDetail: "no daemon — Enter to create a session",
324		Dim: true, Focusable: true, Actions: protocol.PickerCanNavigate,
325	}}, Config{Intent: protocol.PickerIntentNavigation})
326	frame := m.Render(domain.Size{Cols: 80, Rows: 8}, Preview{})
327	require.Contains(t, rowText(frame.Row(0)), "●")
328	require.Contains(t, rowText(frame.Row(7)), "no daemon")
329	require.Contains(t, rowText(frame.Row(7)), "Enter to create a session")
330	require.NotContains(t, rowText(frame.Row(7)), "Enter unavailable")
331}
332
333func TestRenderBlitsThePreviewIntoThePreviewRect(t *testing.T) {
334	m := New([]protocol.PickerLine{navLine("a/one", "one")}, Config{Intent: protocol.PickerIntentNavigation})
335	preview := Preview{Rows: [][]renderer.Cell{{{Rune: 'X'}}}, Width: 1, Height: 1}
336	frame := m.Render(domain.Size{Cols: 120, Rows: 12}, preview)
337	geometry := ChooseGeometry(domain.Size{Cols: 120, Rows: 12})
338	x := geometry.Preview.X
339	y := geometry.Preview.Y + geometry.Preview.Height - 1
340	require.Equal(t, 'X', frame.Cell(x, y).Rune)
341}
342
343func TestRenderAttentionMarkerFollowsTheTabName(t *testing.T) {
344	m := New([]protocol.PickerLine{
345		tabLine("a/one#t1", "shell", protocol.PickerCanNavigate),
346		{Key: "a/one#t2", Kind: protocol.PickerLineTab, Label: "build", Attention: true, Focusable: true, Actions: protocol.PickerCanNavigate},
347	}, Config{Intent: protocol.PickerIntentNavigation})
348	frame := m.Render(domain.Size{Cols: 60, Rows: 6}, Preview{})
349	require.Contains(t, rowText(frame.Row(1)), "build")
350	require.NotEqual(t, rowText(frame.Row(0)), rowText(frame.Row(1)))
351}
352
353// TestRenderAttentionBellOnHeadersAndTabs pins Plan 003 C1: a session header
354// and a tab row both draw the bell right after their name when they ring, and
355// neither draws it otherwise.
356func TestRenderAttentionBellOnHeadersAndTabs(t *testing.T) {
357	bell := string(ui.AttentionGlyph)
358	tests := []struct {
359		name string
360		line protocol.PickerLine
361		want bool
362	}{
363		{name: "ringing header", line: protocol.PickerLine{Key: "s1", Kind: protocol.PickerLineSession, Label: "work", Detail: "attached", Attention: true}, want: true},
364		{name: "quiet header", line: protocol.PickerLine{Key: "s1", Kind: protocol.PickerLineSession, Label: "work", Detail: "attached"}},
365		{name: "ringing tab", line: protocol.PickerLine{Key: "t1", Kind: protocol.PickerLineTab, Label: "build", Attention: true, Focusable: true, Actions: protocol.PickerCanNavigate}, want: true},
366		{name: "quiet tab", line: protocol.PickerLine{Key: "t1", Kind: protocol.PickerLineTab, Label: "build", Focusable: true, Actions: protocol.PickerCanNavigate}},
367	}
368	for _, tt := range tests {
369		t.Run(tt.name, func(t *testing.T) {
370			m := New([]protocol.PickerLine{tt.line}, Config{Intent: protocol.PickerIntentNavigation})
371			text := rowText(m.Render(domain.Size{Cols: 60, Rows: 6}, Preview{}).Row(0))
372			require.Equal(t, tt.want, strings.Contains(text, tt.line.Label+" "+bell), "row %q", text)
373		})
374	}
375}
376
377func TestSearchMatchesLabelsAndDetails(t *testing.T) {
378	m := New([]protocol.PickerLine{
379		navLine("a/one", "one"),
380		navLine("b/two", "two"),
381		{Key: "c/three", Kind: protocol.PickerLineTab, Label: "three", Detail: " (vim)", Focusable: true, Actions: protocol.PickerCanNavigate},
382	}, Config{Intent: protocol.PickerIntentNavigation})
383	m.EnterSearch()
384	m.InsertSearch('v')
385	m.InsertSearch('i')
386	m.InsertSearch('m')
387
388	require.Equal(t, 1, m.MatchCount())
389	selected, ok := m.Selected()
390	require.True(t, ok)
391	require.Equal(t, "c/three", selected.Key)
392	m.BackspaceSearch()
393	require.Equal(t, "vi", m.Query())
394	m.ClearSearch()
395	require.Equal(t, "", m.Query())
396	require.True(t, m.SearchActive(), "clearing the query keeps the search editor open")
397	m.ExitSearch()
398	require.False(t, m.SearchActive())
399}
400
401// TestSearchSessionNameReachesItsTabRows pins that a session with tabs, whose
402// header is not itself a destination, is found by its name: the query matches
403// the tab rows that are its destinations, never another session's tabs.
404func TestSearchSessionNameReachesItsTabRows(t *testing.T) {
405	tab := func(key, label string) protocol.PickerLine {
406		return protocol.PickerLine{Key: key, Kind: protocol.PickerLineTab, Label: label, Focusable: true, Actions: protocol.PickerCanNavigate}
407	}
408	session := func(key, label string) protocol.PickerLine {
409		return protocol.PickerLine{Key: key, Kind: protocol.PickerLineSession, Label: label}
410	}
411	lines := []protocol.PickerLine{
412		session("a", "picka1234"), tab("a/1", "1"),
413		session("b", "pickb1234"), tab("b/1", "1"), tab("b/2", "logs"),
414	}
415	tests := []struct {
416		name        string
417		query       string
418		wantMatches int
419		wantKey     string
420	}{
421		{name: "exact session name", query: "pickb1234", wantMatches: 2, wantKey: "b/1"},
422		{name: "session name prefix", query: "picka", wantMatches: 1, wantKey: "a/1"},
423		{name: "tab label still matches", query: "logs", wantMatches: 1, wantKey: "b/2"},
424		{name: "no session of that name", query: "pickc", wantMatches: 0},
425	}
426	for _, tt := range tests {
427		t.Run(tt.name, func(t *testing.T) {
428			m := New(lines, Config{Intent: protocol.PickerIntentNavigation})
429			m.EnterSearch()
430			for _, r := range tt.query {
431				m.InsertSearch(r)
432			}
433			require.Equal(t, tt.wantMatches, m.MatchCount())
434			selected, ok := m.Selected()
435			if tt.wantKey == "" {
436				require.False(t, ok)
437				return
438			}
439			require.True(t, ok)
440			require.Equal(t, tt.wantKey, selected.Key)
441		})
442	}
443}
444
445func rowText(row []renderer.Cell) string {
446	out := make([]rune, 0, len(row))
447	for _, cell := range row {
448		out = append(out, cell.Rune)
449	}
450	return string(out)
451}
452
453func TestReplaceLinesSelectsThePreviousRowWhenTheCursorRowLeaves(t *testing.T) {
454	all := []protocol.PickerLine{navLine("a", "a"), navLine("b", "b"), navLine("c", "c"), navLine("d", "d")}
455	tests := []struct {
456		name   string
457		cursor string
458		next   []protocol.PickerLine
459		want   string
460	}{
461		{name: "middle row killed", cursor: "c", next: []protocol.PickerLine{all[0], all[1], all[3]}, want: "b"},
462		{name: "last row killed", cursor: "d", next: all[:3], want: "c"},
463		{name: "first row killed falls to the nearest", cursor: "a", next: all[1:], want: "b"},
464		{name: "unchanged row keeps the cursor", cursor: "c", next: all, want: "c"},
465	}
466	for _, tt := range tests {
467		t.Run(tt.name, func(t *testing.T) {
468			m := New(all, Config{Cursor: protocol.PickerCursor{Key: tt.cursor, Index: -1}})
469			m.ReplaceLines(tt.next, protocol.PickerCursor{Index: -1})
470			selected, ok := m.Selected()
471			require.True(t, ok)
472			require.Equal(t, tt.want, selected.Key)
473		})
474	}
475}
476
477func TestTreePrefixesBranchSessionsAndTabs(t *testing.T) {
478	m := New([]protocol.PickerLine{
479		section("local"),
480		navLine("a", "alpha"), tabLine("a1", "one", protocol.PickerCanNavigate), tabLine("a2", "two", protocol.PickerCanNavigate),
481		navLine("b", "beta"), tabLine("b1", "one", protocol.PickerCanNavigate),
482		section("remote"),
483		navLine("c", "gamma"),
484	}, Config{})
485	got := make([]string, 0, len(m.rows))
486	for _, r := range m.rows {
487		got = append(got, r.tree+r.line.Label)
488	}
489	require.Equal(t, []string{
490		"local",
491		"├─ alpha", "│  ├─ one", "│  └─ two",
492		"└─ beta", "   └─ one",
493		"remote",
494		"└─ gamma",
495	}, got)
496
497	frame := m.Render(domain.Size{Cols: 40, Rows: 30}, Preview{})
498	cell := frame.At(0, 1)
499	require.Equal(t, '├', cell.Rune)
500	require.NotZero(t, cell.Style.Attrs&renderer.AttrDim, "the selected row keeps its dimmed tree")
501
502	cell = frame.At(0, 4) // "└─ beta", not selected
503	require.Equal(t, '└', cell.Rune)
504	// A fixed grey, not SGR dim: some terminals barely dim box drawing.
505	require.Equal(t, 244, cell.Style.Foreground, "tree lines use a readable grey")
506	require.False(t, cell.Style.HasForegroundRGB)
507	require.Zero(t, cell.Style.Attrs&renderer.AttrDim)
508}
509
510func TestTreeStyleKeepsRowDimming(t *testing.T) {
511	tests := []struct {
512		name  string
513		lines []protocol.PickerLine
514		query string
515	}{
516		{name: "dim line", lines: func() []protocol.PickerLine {
517			dim := navLine("b", "beta")
518			dim.Dim = true
519			return []protocol.PickerLine{section("local"), navLine("a", "alpha"), dim}
520		}()},
521		{name: "search miss", lines: []protocol.PickerLine{section("local"), navLine("a", "alpha"), navLine("b", "beta")}, query: "alp"},
522	}
523	for _, tt := range tests {
524		t.Run(tt.name, func(t *testing.T) {
525			m := New(tt.lines, Config{})
526			if tt.query != "" {
527				m.EnterSearch()
528				for _, r := range tt.query {
529					m.InsertSearch(r)
530				}
531			}
532			frame := m.Render(domain.Size{Cols: 40, Rows: 30}, Preview{})
533			cell := frame.At(0, 2) // "└─ beta", not selected
534			require.Equal(t, '└', cell.Rune)
535			require.Equal(t, 244, cell.Style.Foreground)
536			require.NotZero(t, cell.Style.Attrs&renderer.AttrDim, "a dimmed row keeps its tree dimmed")
537		})
538	}
539}
540
541func TestDetailLeadingSpaceIsTrimmed(t *testing.T) {
542	tab := tabLine("a1", "1", protocol.PickerCanNavigate)
543	tab.Detail = " (fish)"
544	m := New([]protocol.PickerLine{section("local"), navLine("a", "alpha"), tab}, Config{})
545	frame := m.Render(domain.Size{Cols: 40, Rows: 30}, Preview{})
546	row := ""
547	for x := range 14 {
548		row += string(frame.At(x, 2).Rune)
549	}
550	require.Equal(t, "   └─ 1 (fish)", row, "one gap, not two")
551}
552
553func TestReplaceLinesPreviousRowRespectsSearch(t *testing.T) {
554	m := New([]protocol.PickerLine{navLine("a", "alpha"), navLine("b", "beta"), navLine("c", "alps")}, Config{Cursor: protocol.PickerCursor{Key: "c", Index: -1}})
555	m.EnterSearch()
556	for _, r := range "al" {
557		m.InsertSearch(r)
558	}
559	m.Down() // alpha -> alps: beta is hidden by the query
560	selected, ok := m.Selected()
561	require.True(t, ok)
562	require.Equal(t, "c", selected.Key)
563
564	m.ReplaceLines([]protocol.PickerLine{navLine("a", "alpha"), navLine("b", "beta")}, protocol.PickerCursor{Index: -1})
565
566	selected, ok = m.Selected()
567	require.True(t, ok)
568	require.Equal(t, "a", selected.Key, "the hidden row above is skipped for the nearest matching one")
569}

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