v0.3.0 99dc266

model_test.go RawHistoryFolder

1package picker
2
3import (
4	"reflect"
5	"strings"
6	"testing"
7
8	renderer "github.com/bnema/vev-vt"
9	"github.com/bnema/vev/internal/domain"
10	themeui "github.com/bnema/vev/internal/usecase/theme"
11	"github.com/bnema/vev/internal/usecase/ui"
12	"github.com/stretchr/testify/require"
13)
14
15var (
16	_ domain.TabStableID = TabEntry{}.TabID
17	_ domain.TabStableID = Target{}.TabID
18	_ domain.TabStableID = SourceFilter{}.TabID
19)
20
21func TestStableIdentityTypesAreDistinct(t *testing.T) {
22	require.NotEqual(t, reflect.TypeFor[domain.TabStableID](), reflect.TypeFor[domain.PaneStableID]())
23	require.NotEqual(t, reflect.TypeFor[domain.TabStableID](), reflect.TypeFor[domain.TabID]())
24}
25
26func TestNewFlattensAndSelectsCurrentTab(t *testing.T) {
27	m := New([]SessionView{
28		{ID: "s1", Name: "one", Tabs: []TabEntry{{TabID: "shell", Name: "shell"}, {TabID: "logs", Name: "logs"}}, Active: 0},
29		{ID: "s2", Name: "two", Tabs: []TabEntry{{TabID: "api", Name: "api"}}, Active: 0},
30	}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "s1", TabID: "logs"}})
31
32	got, ok := m.Selected()
33	require.True(t, ok)
34	require.Equal(t, Target{Session: "s1", TabID: "logs", TabIndex: 1}, got)
35	frame := m.Render(domain.Size{Cols: 20, Rows: 5}, Preview{})
36	require.Equal(t, 'o', frame.At(0, 0).Rune)
37	require.Equal(t, ' ', frame.At(0, 2).Rune)
38	require.Equal(t, 'l', frame.At(2, 2).Rune)
39	require.True(t, frame.At(0, 2).Style.Inverse)
40}
41
42func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) {
43	m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 1}}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "missing"}})
44	got, ok := m.Selected()
45	require.True(t, ok)
46	require.Equal(t, Target{Session: "s1", TabIndex: 1}, got)
47
48	m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}}, Active: 4}}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "missing"}})
49	got, ok = m.Selected()
50	require.True(t, ok)
51	require.Equal(t, Target{Session: "s1", TabIndex: 0}, got)
52}
53
54func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) {
55	m := New([]SessionView{
56		{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a"}, {Name: "b"}}, Active: 0},
57		{ID: "s2", Name: "two", Tabs: []TabEntry{{Name: "c"}}, Active: 0},
58	}, SelectionConfig{Mode: SelectNavigationTab})
59
60	m.Up()
61	got, ok := m.Selected()
62	require.True(t, ok)
63	require.Equal(t, Target{Session: "s1", TabIndex: 0}, got, "up at first tab clamps")
64	m.Down()
65	got, ok = m.Selected()
66	require.True(t, ok)
67	require.Equal(t, Target{Session: "s1", TabIndex: 1}, got)
68	m.Down()
69	got, ok = m.Selected()
70	require.True(t, ok)
71	require.Equal(t, Target{Session: "s2", TabIndex: 0}, got, "down skips second session header")
72	m.Down()
73	got, ok = m.Selected()
74	require.True(t, ok)
75	require.Equal(t, Target{Session: "s2", TabIndex: 0}, got, "down at last tab clamps")
76	m.Up()
77	got, ok = m.Selected()
78	require.True(t, ok)
79	require.Equal(t, Target{Session: "s1", TabIndex: 1}, got, "up skips second session header")
80}
81
82func TestModelConstructsTargetsForSelectionModes(t *testing.T) {
83	incarnationOne := domain.IncarnationID{1}
84	incarnationTwo := domain.IncarnationID{2}
85	sessions := []SessionView{
86		{ID: "s1", Name: "one", TargetName: "one", Incarnation: incarnationOne, Tabs: []TabEntry{{TabID: "t1", Name: "shell"}, {TabID: "t2", Name: "logs"}}, Active: 1},
87		{ID: "s2", Name: "two", TargetName: "two", Incarnation: incarnationTwo, Tabs: []TabEntry{{TabID: "t3", Name: "api"}}, Active: 0},
88	}
89	tests := []struct {
90		name   string
91		config SelectionConfig
92		want   Target
93	}{
94		{
95			name:   "navigation selects tab row",
96			config: SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "s1", TabID: "t2"}},
97			want:   Target{Session: "s1", Incarnation: incarnationOne, TabID: "t2", TabIndex: 1},
98		},
99		{
100			name:   "move pane selects eligible tab row",
101			config: SelectionConfig{Mode: SelectMovePaneTab, Current: SourceFilter{Session: "s1", TabID: "t1"}, Source: SourceFilter{Session: "s1", TabID: "t2"}},
102			want:   Target{Session: "s1", Incarnation: incarnationOne, Name: "one", TabID: "t1", TabIndex: 0},
103		},
104		{
105			name:   "move tab selects destination session header",
106			config: SelectionConfig{Mode: SelectMoveTabSession, Current: SourceFilter{Session: "s2"}, Source: SourceFilter{Session: "s1"}},
107			want:   Target{Session: "s2", Incarnation: incarnationTwo, Name: "two", TabIndex: -1},
108		},
109	}
110	for _, tt := range tests {
111		t.Run(tt.name, func(t *testing.T) {
112			model := New(sessions, tt.config)
113
114			got, ok := model.Selected()
115			require.True(t, ok)
116			require.Equal(t, tt.want, got)
117		})
118	}
119}
120
121func TestModelMoveModesApplyExactDestinationFiltering(t *testing.T) {
122	incarnation := domain.IncarnationID{1}
123	sessions := []SessionView{
124		{ID: "source", Incarnation: incarnation, Name: "source", Tabs: []TabEntry{{TabID: "source-tab", Name: "source"}, {TabID: "other-tab", Name: "other"}}},
125		{ID: "only-source", Incarnation: incarnation, Name: "only-source", Tabs: []TabEntry{{TabID: "only-tab", Name: "only"}}},
126		{ID: "destination", Incarnation: incarnation, Name: "destination", Tabs: []TabEntry{{TabID: "destination-tab", Name: "destination"}}},
127		{ID: "empty", Incarnation: incarnation, Name: "empty"},
128		{ID: "stopped", Incarnation: incarnation, Name: "stopped", Stopped: true, Tabs: []TabEntry{{TabID: "stopped-tab", Name: "stopped"}}},
129	}
130	type rowIdentity struct {
131		kind    rowKind
132		session domain.SessionID
133		tab     domain.TabStableID
134	}
135	identities := func(model *Model) []rowIdentity {
136		got := make([]rowIdentity, 0, len(model.rows))
137		for _, pickerRow := range model.rows {
138			got = append(got, rowIdentity{kind: pickerRow.kind, session: pickerRow.session, tab: pickerRow.tabID})
139		}
140		return got
141	}
142
143	paneModel := New(sessions, SelectionConfig{Mode: SelectMovePaneTab, Source: SourceFilter{
144		Session: "only-source", Incarnation: incarnation, TabID: "only-tab",
145	}})
146	require.Equal(t, []rowIdentity{
147		{kind: rowSession, session: "source"},
148		{kind: rowTab, session: "source", tab: "source-tab"},
149		{kind: rowTab, session: "source", tab: "other-tab"},
150		{kind: rowSession, session: "destination"},
151		{kind: rowTab, session: "destination", tab: "destination-tab"},
152	}, identities(paneModel))
153
154	tabModel := New(sessions, SelectionConfig{Mode: SelectMoveTabSession, Source: SourceFilter{
155		Session: "source", Incarnation: incarnation,
156	}})
157	require.Equal(t, []rowIdentity{
158		{kind: rowSession, session: "only-source"},
159		{kind: rowTab, session: "only-source", tab: "only-tab"},
160		{kind: rowSession, session: "destination"},
161		{kind: rowTab, session: "destination", tab: "destination-tab"},
162	}, identities(tabModel))
163
164	replacementIncarnation := domain.IncarnationID{2}
165	replacementModel := New([]SessionView{
166		{ID: "same", Incarnation: incarnation, Tabs: []TabEntry{{TabID: "same-tab"}}},
167		{ID: "same", Incarnation: replacementIncarnation, Tabs: []TabEntry{{TabID: "same-tab"}}},
168	}, SelectionConfig{Mode: SelectMovePaneTab, Source: SourceFilter{
169		Session: "same", Incarnation: incarnation, TabID: "same-tab",
170	}})
171	require.Len(t, replacementModel.rows, 2, "only the exact source lifecycle is filtered")
172	require.Equal(t, replacementIncarnation, replacementModel.rows[0].incarnation)
173	require.Equal(t, replacementIncarnation, replacementModel.rows[1].incarnation)
174}
175
176func TestRowKindDefinesRenderingAndSelectability(t *testing.T) {
177	tests := []struct {
178		name       string
179		kind       rowKind
180		mode       SelectionMode
181		header     bool
182		selectable bool
183	}{
184		{name: "navigation session", kind: rowSession, mode: SelectNavigationTab, header: true},
185		{name: "navigation tab", kind: rowTab, mode: SelectNavigationTab, selectable: true},
186		{name: "move pane session", kind: rowSession, mode: SelectMovePaneTab, header: true},
187		{name: "move pane tab", kind: rowTab, mode: SelectMovePaneTab, selectable: true},
188		{name: "move tab session", kind: rowSession, mode: SelectMoveTabSession, header: true, selectable: true},
189		{name: "move tab tab", kind: rowTab, mode: SelectMoveTabSession},
190	}
191	for _, tt := range tests {
192		t.Run(tt.name, func(t *testing.T) {
193			require.Equal(t, tt.header, tt.kind.rendersAsHeader())
194			require.Equal(t, tt.selectable, tt.kind.selectable(tt.mode))
195		})
196	}
197}
198
199func TestModelMovePaneFallsBackToFirstEligibleTab(t *testing.T) {
200	model := New([]SessionView{
201		{ID: "source", Name: "source", Active: 0, Tabs: []TabEntry{{TabID: "source-tab", Name: "source"}, {TabID: "sibling", Name: "sibling"}}},
202		{ID: "destination", Name: "destination", Active: 0, Tabs: []TabEntry{{TabID: "destination-tab", Name: "destination"}}},
203	}, SelectionConfig{Mode: SelectMovePaneTab, Source: SourceFilter{Session: "source", TabID: "source-tab"}})
204
205	got, ok := model.Selected()
206	require.True(t, ok)
207	require.Equal(t, domain.TabStableID("sibling"), got.TabID)
208}
209
210func TestModelMoveNavigationSkipsRowsNotSelectableForMode(t *testing.T) {
211	sessions := []SessionView{
212		{ID: "s1", Name: "one", Tabs: []TabEntry{{TabID: "t1", Name: "one"}}},
213		{ID: "s2", Name: "two", Tabs: []TabEntry{{TabID: "t2", Name: "two"}}},
214	}
215
216	paneModel := New(sessions, SelectionConfig{Mode: SelectMovePaneTab, Current: SourceFilter{Session: "s1", TabID: "t1"}})
217	paneModel.Down()
218	got, ok := paneModel.Selected()
219	require.True(t, ok)
220	require.Equal(t, domain.TabStableID("t2"), got.TabID, "move-pane navigation skips the second session header")
221
222	tabModel := New(sessions, SelectionConfig{Mode: SelectMoveTabSession, Current: SourceFilter{Session: "s1"}})
223	tabModel.Down()
224	got, ok = tabModel.Selected()
225	require.True(t, ok)
226	require.Equal(t, domain.SessionID("s2"), got.Session, "move-tab navigation skips tab rows")
227}
228
229func TestModelSelectedKeepsImmutableLifecycleAndStableTabAcrossRefresh(t *testing.T) {
230	createdAt := int64(41)
231	views := []SessionView{{
232		ID: "same-id", Name: "same-name", TargetName: "same-name", Incarnation: domain.IncarnationID{1}, ExpectedCreatedAt: &createdAt,
233		Tabs: []TabEntry{{TabID: "first", Name: "first"}, {TabID: "stable", Name: "stable"}}, Active: 1,
234	}}
235	model := New(views, SelectionConfig{Mode: SelectMovePaneTab, Current: SourceFilter{Session: "same-id", TabID: "stable"}})
236
237	views[0].Incarnation = domain.IncarnationID{9}
238	views[0].TargetName = "replacement"
239	views[0].Tabs[1].TabID = "replacement-tab"
240	createdAt = 99
241	got, ok := model.Selected()
242	require.True(t, ok)
243	require.Equal(t, Target{
244		Session: "same-id", Incarnation: domain.IncarnationID{1}, Name: "same-name", TabID: "stable", TabIndex: 1,
245		ExpectedCreatedAt: int64Pointer(41),
246	}, got, "model target remains bound to the lifecycle captured during construction")
247
248	refreshed := New([]SessionView{{
249		ID: "same-id", Name: "same-name", TargetName: "same-name", Incarnation: domain.IncarnationID{1},
250		Tabs: []TabEntry{{TabID: "stable", Name: "stable"}, {TabID: "first", Name: "first"}}, Active: 1,
251	}}, SelectionConfig{Mode: SelectMovePaneTab, Current: SourceFilter{Session: got.Session, TabID: got.TabID}})
252	got, ok = refreshed.Selected()
253	require.True(t, ok)
254	require.Equal(t, domain.TabStableID("stable"), got.TabID)
255	require.Equal(t, 0, got.TabIndex, "mutable index follows the stable tab after refresh")
256}
257
258func TestModelClonePreservesModeAndImmutableTarget(t *testing.T) {
259	createdAt := int64(7)
260	model := New([]SessionView{{
261		ID: "s", Name: "session", TargetName: "session", Incarnation: domain.IncarnationID{3}, ExpectedCreatedAt: &createdAt,
262		Tabs: []TabEntry{{TabID: "tab", Name: "tab"}},
263	}}, SelectionConfig{Mode: SelectMovePaneTab})
264	clone := model.Clone()
265
266	require.Equal(t, SelectMovePaneTab, clone.mode)
267	got, ok := clone.Selected()
268	require.True(t, ok)
269	*got.ExpectedCreatedAt = 100
270	gotAgain, ok := clone.Selected()
271	require.True(t, ok)
272	require.Equal(t, int64(7), *gotAgain.ExpectedCreatedAt, "returned targets cannot mutate the cloned model locator")
273	require.Equal(t, domain.TabStableID("tab"), gotAgain.TabID)
274	require.Equal(t, domain.IncarnationID{3}, gotAgain.Incarnation)
275}
276
277func int64Pointer(value int64) *int64 {
278	return new(value)
279}
280
281func TestChooseLayoutResponsiveBoundaries(t *testing.T) {
282	tests := []struct {
283		name string
284		size domain.Size
285		want Layout
286	}{
287		{
288			name: "horizontal rows minus one is list only", size: domain.Size{Cols: 69, Rows: 3},
289			want: Layout{Mode: LayoutListOnly, List: domain.Rect{Width: 69, Height: 3}},
290		},
291		{
292			name: "horizontal preview minus one is list only", size: domain.Size{Cols: 68, Rows: 4},
293			want: Layout{Mode: LayoutListOnly, List: domain.Rect{Width: 68, Height: 4}},
294		},
295		{
296			name: "horizontal exact minimum preview", size: domain.Size{Cols: 69, Rows: 4},
297			want: Layout{Mode: LayoutHorizontal, List: domain.Rect{Width: 20, Height: 4}, Separator: domain.Rect{X: 20, Width: 1, Height: 4}, Preview: domain.Rect{X: 21, Width: 48, Height: 4}},
298		},
299		{
300			name: "horizontal preview plus one", size: domain.Size{Cols: 70, Rows: 4},
301			want: Layout{Mode: LayoutHorizontal, List: domain.Rect{Width: 21, Height: 4}, Separator: domain.Rect{X: 21, Width: 1, Height: 4}, Preview: domain.Rect{X: 22, Width: 48, Height: 4}},
302		},
303		{
304			name: "stacked columns minus one is list only", size: domain.Size{Cols: 23, Rows: 12},
305			want: Layout{Mode: LayoutListOnly, List: domain.Rect{Width: 23, Height: 12}},
306		},
307		{
308			name: "stacked rows minus one is list only", size: domain.Size{Cols: 24, Rows: 11},
309			want: Layout{Mode: LayoutListOnly, List: domain.Rect{Width: 24, Height: 11}},
310		},
311		{
312			name: "stacked exact minimum", size: domain.Size{Cols: 24, Rows: 12},
313			want: Layout{Mode: LayoutStacked, List: domain.Rect{Width: 24, Height: 4}, Separator: domain.Rect{Y: 4, Width: 24, Height: 1}, Preview: domain.Rect{Y: 5, Width: 24, Height: 7}},
314		},
315		{
316			name: "stacked list height plus one", size: domain.Size{Cols: 24, Rows: 13},
317			want: Layout{Mode: LayoutStacked, List: domain.Rect{Width: 24, Height: 5}, Separator: domain.Rect{Y: 5, Width: 24, Height: 1}, Preview: domain.Rect{Y: 6, Width: 24, Height: 7}},
318		},
319	}
320	for _, tt := range tests {
321		t.Run(tt.name, func(t *testing.T) {
322			require.Equal(t, tt.want, ChooseLayout(tt.size))
323		})
324	}
325}
326
327func TestRenderDrawsCustomOrientedSeparators(t *testing.T) {
328	m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
329	separator := renderer.Style{Foreground: 8, Attrs: renderer.AttrDim}
330	styles := RenderStyles{Separator: separator}
331	tests := []struct {
332		name string
333		size domain.Size
334		rect domain.Rect
335		rune rune
336	}{
337		{"horizontal layout", domain.Size{Cols: 69, Rows: 4}, domain.Rect{X: 20, Width: 1, Height: 4}, '│'},
338		{"stacked layout", domain.Size{Cols: 24, Rows: 12}, domain.Rect{Y: 4, Width: 24, Height: 1}, '─'},
339	}
340	for _, tt := range tests {
341		t.Run(tt.name, func(t *testing.T) {
342			frame := m.Render(tt.size, Preview{}, styles)
343			for y := tt.rect.Y; y < tt.rect.Y+tt.rect.Height; y++ {
344				for x := tt.rect.X; x < tt.rect.X+tt.rect.Width; x++ {
345					got := frame.At(x, y)
346					require.Equal(t, tt.rune, got.Rune)
347					require.True(t, got.Style.Equal(separator))
348				}
349			}
350		})
351	}
352}
353
354func TestRenderListOnlyOmitsSeparatorAndPreview(t *testing.T) {
355	m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
356	frame := m.Render(domain.Size{Cols: 23, Rows: 11}, Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}, RenderStyles{Separator: renderer.Style{Foreground: 8}})
357
358	require.Equal(t, ' ', frame.At(22, 10).Rune)
359	require.NotEqual(t, 'x', frame.At(22, 10).Rune)
360}
361
362func TestRenderPreviewAnchorsOversizedSourceToFinalRows(t *testing.T) {
363	m := New(nil, SelectionConfig{Mode: SelectNavigationTab})
364	preview := Preview{Width: 24, Height: 9, Rows: previewRows(24, "abcdefghi")}
365
366	frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview)
367	require.Equal(t, 'c', frame.At(0, 5).Rune)
368	require.Equal(t, 'i', frame.At(0, 11).Rune)
369}
370
371func TestRenderPreviewBottomPlacesShortSource(t *testing.T) {
372	m := New(nil, SelectionConfig{Mode: SelectNavigationTab})
373	preview := Preview{Width: 24, Height: 1, Rows: previewRows(24, "z")}
374
375	frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview)
376	require.Equal(t, ' ', frame.At(0, 5).Rune)
377	require.Equal(t, 'z', frame.At(0, 11).Rune)
378}
379
380func TestRenderAttentionMarkerSmokeWithResponsiveLayout(t *testing.T) {
381	m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab", Attention: true}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
382	frame := m.Render(domain.Size{Cols: 69, Rows: 4}, Preview{})
383
384	require.Equal(t, rune(ui.AttentionGlyph), frame.At(6, 1).Rune)
385}
386
387func previewRows(width int, labels string) [][]renderer.Cell {
388	rows := make([][]renderer.Cell, len(labels))
389	for y, label := range labels {
390		rows[y] = make([]renderer.Cell, width)
391		for x := range rows[y] {
392			rows[y][x] = cell(' ')
393		}
394		rows[y][0] = cell(label)
395	}
396	return rows
397}
398
399func TestSelectedMapping(t *testing.T) {
400	m := New([]SessionView{
401		{ID: "alpha", Name: "alpha", Tabs: []TabEntry{{TabID: "one", Name: "one"}}, Active: 0},
402		{ID: "beta", Name: "beta", Tabs: []TabEntry{{TabID: "two", Name: "two"}, {TabID: "three", Name: "three"}}, Active: 0},
403	}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "beta", TabID: "three"}})
404
405	got, ok := m.Selected()
406	require.True(t, ok)
407	require.Equal(t, Target{Session: "beta", TabID: "three", TabIndex: 1}, got)
408}
409
410func TestSelectNearestRow(t *testing.T) {
411	// Three single-tab sessions: rows are [hdrA, tabA, hdrB, tabB, hdrC, tabC].
412	sessions := []SessionView{
413		{ID: "a", Name: "a", Tabs: []TabEntry{{TabID: "ta", Name: "ta"}}},
414		{ID: "b", Name: "b", Tabs: []TabEntry{{TabID: "tb", Name: "tb"}}},
415		{ID: "c", Name: "c", Tabs: []TabEntry{{TabID: "tc", Name: "tc"}}},
416	}
417	tests := []struct {
418		name string
419		idx  int
420		cfg  SelectionConfig
421		// wantStart, when set, pins the selection New produced before
422		// SelectNearestRow runs, so a case cannot silently degrade into one a
423		// no-op implementation would also pass.
424		wantStart domain.SessionID
425		want      domain.SessionID
426	}{
427		{name: "exact selectable row", idx: 3, cfg: SelectionConfig{Mode: SelectNavigationTab}, want: "b"},
428		{name: "header row snaps to its tab", idx: 2, cfg: SelectionConfig{Mode: SelectNavigationTab}, want: "b"},
429		{name: "past end clamps to last selectable", idx: 40, cfg: SelectionConfig{Mode: SelectNavigationTab}, want: "c"},
430		{
431			// Starts selection on "c" (via Current) so a no-op SelectNearestRow
432			// would leave the selection on "c" and this case would fail; the
433			// plain SelectNavigationTab config used above starts on "a" already,
434			// which can't tell a real clamp-to-first from a no-op.
435			name:      "negative clamps to first selectable",
436			idx:       -2,
437			cfg:       SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "c", TabID: "tc"}},
438			wantStart: "c",
439			want:      "a",
440		},
441		{name: "last row is a tab and stays put", idx: 5, cfg: SelectionConfig{Mode: SelectNavigationTab}, want: "c"},
442	}
443	for _, tc := range tests {
444		t.Run(tc.name, func(t *testing.T) {
445			m := New(sessions, tc.cfg)
446			if tc.wantStart != "" {
447				pre, ok := m.Selected()
448				require.True(t, ok)
449				require.Equal(t, tc.wantStart, pre.Session, "guard: starting selection must not already be the expected result")
450			}
451			m.SelectNearestRow(tc.idx)
452			target, ok := m.Selected()
453			require.True(t, ok)
454			require.Equal(t, tc.want, target.Session)
455		})
456	}
457}
458
459func TestSelectNearestRowFallsBackBackwardAndToleratesEmptyModels(t *testing.T) {
460	// Move-tab mode makes only session headers selectable, so the trailing tab
461	// row has no selectable row at or after it.
462	m := New([]SessionView{
463		{ID: "a", Name: "a", Tabs: []TabEntry{{TabID: "ta", Name: "ta"}}},
464		{ID: "b", Name: "b", Tabs: []TabEntry{{TabID: "tb", Name: "tb"}}},
465	}, SelectionConfig{Mode: SelectMoveTabSession, Source: SourceFilter{Session: "zzz"}})
466	require.Equal(t, 0, m.SelectedIndex(), "guard: the backward fallback below must move the selection off this starting row")
467	m.SelectNearestRow(3)
468	target, ok := m.Selected()
469	require.True(t, ok)
470	require.Equal(t, domain.SessionID("b"), target.Session)
471	require.Equal(t, 2, m.SelectedIndex())
472
473	empty := New(nil, SelectionConfig{Mode: SelectNavigationTab})
474	empty.SelectNearestRow(0)
475	_, ok = empty.Selected()
476	require.False(t, ok)
477	require.Equal(t, -1, empty.SelectedIndex())
478
479	var nilModel *Model
480	nilModel.SelectNearestRow(3)
481	require.Equal(t, -1, nilModel.SelectedIndex())
482}
483
484func TestSelectedIndexReportsRawSelectedRow(t *testing.T) {
485	m := New([]SessionView{
486		{ID: "alpha", Name: "alpha", Tabs: []TabEntry{{TabID: "one", Name: "one"}, {TabID: "two", Name: "two"}}},
487	}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "alpha", TabID: "two"}})
488
489	require.Equal(t, 2, m.SelectedIndex())
490	m.Up()
491	require.Equal(t, 1, m.SelectedIndex())
492}
493
494func TestStoppedSessionRendersDownSuffix(t *testing.T) {
495	for _, tt := range []struct {
496		name        string
497		sessionID   domain.SessionID
498		sessionName string
499		size        domain.Size
500	}{
501		{name: "normal", sessionID: "stopped:work", sessionName: "work", size: domain.Size{Cols: 24, Rows: 4}},
502		{name: "constrained", sessionID: "stopped:long", sessionName: "long-session", size: domain.Size{Cols: 8, Rows: 2}},
503	} {
504		t.Run(tt.name, func(t *testing.T) {
505			m := New([]SessionView{{ID: tt.sessionID, Name: tt.sessionName, Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, SelectionConfig{Mode: SelectNavigationTab})
506			got, ok := m.Selected()
507			require.True(t, ok)
508			require.Equal(t, Target{Session: tt.sessionID, Name: tt.sessionName, TabIndex: 0, Stopped: true}, got)
509
510			frame := m.Render(tt.size, Preview{})
511			var label strings.Builder
512			for _, cell := range frame.Row(0) {
513				label.WriteRune(cell.Rune)
514			}
515			require.Contains(t, label.String(), "(down)")
516		})
517	}
518}
519
520func TestRenderStopsStoppedRowsDimItalic(t *testing.T) {
521	live := SessionView{ID: "live", Name: "work", Tabs: []TabEntry{{TabID: "t1", Name: "tab"}}}
522	halted := SessionView{ID: "stopped:old", Name: "old", TargetName: "old", Stopped: true, Tabs: []TabEntry{{}}}
523	m := New([]SessionView{live, halted}, SelectionConfig{Mode: SelectNavigationTab})
524
525	stoppedStyle := renderer.Style{Foreground: -1, Background: -1, Italic: true, Attrs: renderer.AttrDim}
526	styles := defaultRenderStyles()
527	require.Equal(t, stoppedStyle, styles.Stopped)
528
529	frame := m.Render(domain.Size{Cols: 15, Rows: 6}, Preview{})
530	// Rows: 0 "work" header, 1 "  tab" (selected), 2 "old (down)" header, 3 its tab row.
531	require.Equal(t, stoppedStyle, frame.Row(2)[0].Style, "stopped header must be dim italic")
532	require.Equal(t, stoppedStyle, frame.Row(3)[0].Style, "stopped tab row must be dim italic")
533	require.NotEqual(t, stoppedStyle, frame.Row(0)[0].Style, "live header keeps base style")
534
535	selected := New([]SessionView{live, halted}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: halted.ID}})
536	selectedFrame := selected.Render(domain.Size{Cols: 15, Rows: 6}, Preview{})
537	require.NotEqual(t, stoppedStyle, selectedFrame.Row(3)[0].Style, "selected stopped row keeps selection style, not Stopped")
538	require.True(t, selectedFrame.Row(3)[0].Style.Inverse, "selected stopped row still shows selection")
539}
540
541func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
542	m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
543	preview := Preview{
544		Width:  25,
545		Height: 1,
546		Rows: [][]renderer.Cell{{
547			cell('a'), cell('b'), cell('c'), cell('d'), cell('e'), cell('f'), cell('g'), cell('h'), cell('i'), cell('j'),
548			cell('k'), cell('l'), cell('m'), cell('n'), cell('o'), cell('p'), cell('q'), cell('r'), cell('s'), cell('t'),
549			cell('u'), cell('v'), cell('w'), {Rune: '界', Style: renderer.DefaultStyle()}, {Continuation: true, Style: renderer.DefaultStyle()},
550		}},
551	}
552
553	frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview)
554	require.True(t, frame.At(0, 1).Style.Inverse)
555	require.Equal(t, 'a', frame.At(0, 11).Rune)
556	require.Equal(t, 'w', frame.At(22, 11).Rune)
557	require.Equal(t, ' ', frame.At(23, 11).Rune, "wide rune crossing preview pane is dropped")
558	require.Equal(t, ' ', frame.At(0, 5).Rune, "short preview is bottom anchored")
559}
560
561func TestRenderListScrollsSelectionIntoView(t *testing.T) {
562	m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a"}, {Name: "b"}, {Name: "c"}, {Name: "d"}, {Name: "e"}, {Name: "f"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
563	for range 5 {
564		m.Down()
565	}
566
567	frame := m.Render(domain.Size{Cols: 23, Rows: 4}, Preview{})
568	require.Equal(t, ' ', frame.At(0, 3).Rune)
569	require.Equal(t, 'f', frame.At(2, 3).Rune)
570	require.True(t, frame.At(0, 3).Style.Inverse)
571}
572
573func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) {
574	m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a-really-long-focused-pane-tab-label"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
575
576	frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{})
577
578	layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5})
579	require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column")
580	require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "truncated label should end with an ellipsis at the list edge")
581	require.Equal(t, '│', frame.At(layout.List.Width, 1).Rune, "the separator occupies the cell after the list")
582}
583
584func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) {
585	m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
586	preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}
587
588	frame := m.Render(domain.Size{Cols: 23, Rows: 11}, preview)
589	require.Equal(t, 'o', frame.At(0, 0).Rune)
590	require.Equal(t, ' ', frame.At(0, 1).Rune)
591	require.NotEqual(t, 'x', frame.At(0, 0).Rune)
592}
593
594func TestRenderListDrawsNameAndDetailSegmentsWithDistinctStyles(t *testing.T) {
595	nameStyle := renderer.Style{Bold: true}
596	detailStyle := renderer.Style{Italic: true}
597	baseStyle := renderer.DefaultStyle()
598	selectionStyle := renderer.Style{Inverse: true}
599	selectionNameStyle := renderer.Style{Inverse: true, Bold: true}
600	selectionMutedStyle := renderer.Style{Inverse: true, Italic: true}
601	styles := RenderStyles{
602		Selection: selectionStyle, SelectionName: selectionNameStyle, SelectionMuted: selectionMutedStyle,
603		Name: nameStyle, Detail: detailStyle, Base: baseStyle,
604	}
605
606	m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{
607		{Name: "alpha", Detail: " (up)"},
608		{Name: "beta", Detail: " (idle)", Attention: true},
609	}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
610
611	frame := m.Render(domain.Size{Cols: 30, Rows: 5}, Preview{}, styles)
612
613	// Row 1 is the selected tab ("alpha"): name uses SelectionName, detail uses SelectionMuted.
614	require.Equal(t, 'a', frame.At(2, 1).Rune)
615	require.True(t, frame.At(2, 1).Style.Equal(selectionNameStyle), "selected name segment style")
616	require.Equal(t, ' ', frame.At(7, 1).Rune)
617	require.True(t, frame.At(7, 1).Style.Equal(selectionMutedStyle), "selected detail segment style")
618	require.Equal(t, '(', frame.At(8, 1).Rune)
619	require.True(t, frame.At(8, 1).Style.Equal(selectionMutedStyle), "selected detail segment style")
620
621	// Row 2 is the non-selected tab ("beta"): name uses Name, then the
622	// attention marker (right after the name, before detail) uses the row's
623	// base style (not muted), then detail uses Detail.
624	require.Equal(t, 'b', frame.At(2, 2).Rune)
625	require.True(t, frame.At(2, 2).Style.Equal(nameStyle), "name segment style")
626	require.Equal(t, ' ', frame.At(6, 2).Rune, "attention marker leading space")
627	require.True(t, frame.At(6, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted")
628	require.Equal(t, rune(ui.AttentionGlyph), frame.At(7, 2).Rune)
629	require.True(t, frame.At(7, 2).Style.Equal(baseStyle), "attention marker uses the base style, not muted")
630	require.Equal(t, ' ', frame.At(8, 2).Rune)
631	require.True(t, frame.At(8, 2).Style.Equal(detailStyle), "detail segment style")
632	require.Equal(t, '(', frame.At(9, 2).Rune)
633	require.True(t, frame.At(9, 2).Style.Equal(detailStyle), "detail segment style")
634}
635
636func TestRenderListTruncatesDetailBeforeName(t *testing.T) {
637	m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{
638		{Name: "short-name", Detail: " (a very long detail text)"},
639	}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
640
641	frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{})
642	layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5})
643	require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column")
644
645	want := "  short-name (a ver…"
646	for i, r := range want {
647		require.Equal(t, r, frame.At(i, 1).Rune, "cell %d", i)
648	}
649	require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "detail is ellipsized to fit, the intact name is not touched")
650	require.Equal(t, '│', frame.At(layout.List.Width, 1).Rune, "the separator occupies the cell after the list")
651}
652
653func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) {
654	m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{
655		{Name: "a-really-long-focused-pane-tab-label", Detail: " (detail)"},
656	}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
657
658	frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{})
659	layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5})
660	require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column")
661
662	require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "the name segment itself is ellipsized once it alone exceeds the width")
663	require.Equal(t, '│', frame.At(layout.List.Width, 1).Rune, "the separator occupies the cell after the list")
664}
665
666func cell(r rune) renderer.Cell {
667	return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
668}
669
670func TestRenderStylesFillBackgroundRowsAndSelection(t *testing.T) {
671	m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
672	background := renderer.Style{Foreground: 1, Background: 2}
673	base := renderer.Style{Foreground: 3, Background: 4}
674	selection := renderer.Style{Foreground: 5, Background: 6}
675	frame := m.Render(domain.Size{Cols: 20, Rows: 5}, Preview{}, RenderStyles{
676		Background: background, Base: base, Name: base, Detail: base,
677		Selection: selection, SelectionName: selection, SelectionMuted: selection,
678		Separator: base,
679	})
680
681	require.True(t, frame.At(19, 4).Style.Equal(background), "unused interior keeps modal base")
682	require.True(t, frame.At(19, 0).Style.Equal(base), "ordinary row owns inactive surface")
683	require.True(t, frame.At(19, 1).Style.Equal(selection), "selected row owns active surface")
684}
685
686func TestPickerRowsKeepTerminalBackgroundAcrossAccentFallbacks(t *testing.T) {
687	palette := [16]renderer.RGB{}
688	palette[2] = renderer.RGB{R: 10, G: 230, B: 120}
689	palette[10] = palette[2]
690	accentTheme := themeui.Theme{
691		Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
692		HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
693		Palette: palette, PaletteKnown: 1<<2 | 1<<10,
694	}
695	indexedTheme := accentTheme
696	indexedTheme.TrueColor = false
697	paletteOffTheme := accentTheme
698	paletteOffTheme.UsePalette = false
699	neutralTheme := accentTheme
700	neutralTheme.UsePalette = false
701	neutralTheme.PaletteKnown = 0
702
703	tests := []struct {
704		name   string
705		theme  themeui.Theme
706		policy domain.ThemeAccent
707	}{
708		{name: "truecolor accent", theme: accentTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
709		{name: "indexed only", theme: indexedTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
710		{name: "palette off", theme: paletteOffTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
711		{name: "forced dark", theme: themeui.BuiltinDark, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
712		{name: "forced light", theme: themeui.BuiltinLight, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
713		{name: "neutral fallback", theme: neutralTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
714	}
715
716	model := New([]SessionView{
717		{ID: "selected", Name: "selected", Tabs: []TabEntry{{Name: "one"}}, Active: 0},
718		{ID: "inactive", Name: "inactive", Tabs: []TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0},
719	}, SelectionConfig{Mode: SelectNavigationTab})
720	for _, tt := range tests {
721		t.Run(tt.name, func(t *testing.T) {
722			styles := themeui.Resolve(tt.theme, tt.policy).Styles
723			require.True(t, styles.PickerBase.Equal(renderer.DefaultStyle()))
724			require.False(t, styles.PickerDescription.HasBackgroundRGB)
725			require.False(t, styles.PickerSeparator.HasBackgroundRGB)
726			if tt.name == "indexed only" {
727				require.Equal(t, 2, styles.PickerDescription.Foreground)
728				require.Equal(t, 2, styles.PickerSeparator.Foreground)
729			}
730
731			frame := model.Render(domain.Size{Cols: 32, Rows: 4}, Preview{}, RenderStyles{
732				Background: styles.PickerBase, Base: styles.PickerBase, Name: styles.PickerName,
733				Detail: styles.PickerDescription, Selection: styles.PickerSelection,
734				SelectionName: styles.PickerSelection, SelectionMuted: styles.PickerSelection,
735				Separator: styles.PickerSeparator,
736			})
737			require.True(t, frame.At(5, 3).Style.Equal(styles.PickerDescription), "description text keeps a contrast-derived foreground without a background tint")
738			require.True(t, frame.At(31, 3).Style.Equal(styles.PickerBase), "unused row cells retain the terminal background")
739		})
740	}
741}
742
743func TestRemoteRowsCarryStructuredIdentityAndRemainNonSelectable(t *testing.T) {
744	key := domain.RemoteSessionKey{Host: "build@mule", Name: "work"}
745	availability := []RemoteAvailability{RemoteCached, RemoteFresh, RemoteStale, RemoteVersionMismatch}
746	sessions := make([]SessionView, 0, len(availability))
747	for _, state := range availability {
748		sessions = append(sessions, SessionView{
749			ID:                 key.ID(),
750			Name:               "presentation text is not routing data",
751			Tabs:               []TabEntry{{Name: "relayed metadata is not a picker target"}},
752			RemoteKey:          &key,
753			RemoteAvailability: state,
754			RemoteDetail:       "remote state detail",
755			RemoteActivation:   RemoteUnavailable,
756			ConnectOnly:        true,
757		})
758	}
759
760	model := New(sessions, SelectionConfig{Mode: SelectNavigationTab})
761	require.Len(t, model.rows, len(availability), "connect-only remote rows do not synthesize tab destinations")
762	for i, pickerRow := range model.rows {
763		require.Equal(t, rowSession, pickerRow.kind)
764		require.Equal(t, key, pickerRow.remoteKey)
765		require.True(t, pickerRow.hasRemoteKey)
766		require.False(t, pickerRow.selectable, "remote state %d must remain gated in phase 5", availability[i])
767		require.True(t, pickerRow.dim, "remote state %d is visibly unavailable", availability[i])
768		require.Equal(t, "@mule (remote state detail)", pickerRow.detail)
769	}
770	_, ok := model.Selected()
771	require.False(t, ok)
772	frame := model.Render(domain.Size{Cols: 80, Rows: len(availability)}, Preview{})
773	for y := range availability {
774		require.NotZero(t, frame.At(0, y).Style.Attrs&renderer.AttrDim)
775	}
776}
777
778func TestRemoteCursorIdentitySurvivesCacheToLiveUpgradeWithoutActivation(t *testing.T) {
779	key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
780	cached := New([]SessionView{{
781		ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: RemoteCached,
782		RemoteActivation: RemoteUnavailable, ConnectOnly: true,
783	}}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: key.ID()}})
784
785	cursor, ok := cached.Cursor()
786	require.True(t, ok)
787	require.Equal(t, key.ID(), cursor.Session)
788	require.Equal(t, &key, cursor.RemoteKey)
789	_, selectable := cached.Selected()
790	require.False(t, selectable, "cursor identity must not bypass the phase 5 activation gate")
791
792	live := New([]SessionView{{
793		ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteAvailability: RemoteFresh,
794		RemoteActivation: RemoteUnavailable, ConnectOnly: true,
795	}}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{
796		Session: cursor.Session, Incarnation: cursor.Incarnation, TabID: cursor.TabID, RemoteKey: cursor.RemoteKey,
797	}})
798
799	upgraded, ok := live.Cursor()
800	require.True(t, ok)
801	require.Equal(t, cursor, upgraded)
802	_, selectable = live.Selected()
803	require.False(t, selectable, "cache-to-live cursor preservation must not enable attach")
804}
805
806func TestRemoteTabsRemainNonSelectableWhileAttachmentIsGated(t *testing.T) {
807	key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
808	model := New([]SessionView{{
809		ID:                 key.ID(),
810		Name:               key.Display(),
811		Tabs:               []TabEntry{{TabID: "remote-tab", Name: "metadata"}},
812		RemoteKey:          &key,
813		RemoteAvailability: RemoteFresh,
814		RemoteActivation:   RemoteUnavailable,
815	}}, SelectionConfig{Mode: SelectNavigationTab})
816
817	require.Len(t, model.rows, 2)
818	for _, pickerRow := range model.rows {
819		require.False(t, pickerRow.selectable)
820		require.True(t, pickerRow.dim)
821	}
822}
823
824func TestRemoteConnectOnlyTargetUsesStructuredKey(t *testing.T) {
825	key := domain.RemoteSessionKey{Host: "user@build.example", Name: "work"}
826	model := New([]SessionView{{
827		ID:                 key.ID(),
828		Name:               "not a parseable remote display label",
829		RemoteKey:          &key,
830		RemoteAvailability: RemoteFresh,
831		RemoteActivation:   RemoteAttach,
832		ConnectOnly:        true,
833	}}, SelectionConfig{Mode: SelectNavigationTab})
834	wantKey := key
835	key.Host = "mutated-after-model-construction"
836
837	got, ok := model.Selected()
838	require.True(t, ok)
839	require.Equal(t, wantKey.ID(), got.Session)
840	require.Equal(t, &wantKey, got.RemoteKey)
841	require.Equal(t, "", got.Name, "connect-only remote routing never derives a name from display text")
842}
843
844func TestRemoteRowsCannotAcceptMovesInEitherMoveMode(t *testing.T) {
845	key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
846	local := SessionView{ID: "local", Name: "local", Tabs: []TabEntry{{TabID: "local-tab", Name: "local"}}}
847	availability := []struct {
848		name  string
849		state RemoteAvailability
850	}{
851		{name: "cached", state: RemoteCached},
852		{name: "fresh", state: RemoteFresh},
853		{name: "stale", state: RemoteStale},
854		{name: "version mismatch", state: RemoteVersionMismatch},
855	}
856	modes := []struct {
857		name string
858		mode SelectionMode
859	}{
860		{name: "move pane", mode: SelectMovePaneTab},
861		{name: "move tab", mode: SelectMoveTabSession},
862	}
863
864	for _, availabilityCase := range availability {
865		t.Run(availabilityCase.name, func(t *testing.T) {
866			remote := SessionView{
867				ID:                 key.ID(),
868				Name:               key.Display(),
869				Tabs:               []TabEntry{{TabID: "remote-tab", Name: "metadata"}},
870				RemoteKey:          &key,
871				RemoteAvailability: availabilityCase.state,
872				RemoteActivation:   RemoteAttach,
873				CannotAcceptMoves:  true,
874			}
875			for _, mode := range modes {
876				t.Run(mode.name, func(t *testing.T) {
877					model := New([]SessionView{local, remote}, SelectionConfig{Mode: mode.mode, Source: SourceFilter{Session: "source"}})
878					var remoteRow *row
879					for i := range model.rows {
880						if model.rows[i].session == key.ID() {
881							remoteRow = &model.rows[i]
882							break
883						}
884					}
885					require.NotNil(t, remoteRow)
886					require.True(t, remoteRow.dim)
887					require.False(t, remoteRow.selectable)
888				})
889			}
890		})
891	}
892}

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