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}