detached ae3ded7

feat(picker): keep selection on the row replacing a killed session

commit
ae3ded7d0f3c5bc2d2ca44964733d5a749de78dd
author
brice · · 2026-07-29 14:32 UTC
parent
4fcfd05
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +185 -43
M internal/usecase/daemon/move_picker.go +5 -0
@@ -175,6 +175,11 @@ func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions
175175 return
176176 }
177177 }
178+ // Navigate-only: move intents keep the selection-or-close logic above
179+ // authoritative, so a stale row index can never override it.
180+ if intent == pickerNavigate && opts.nearestRow >= 0 {
181+ model.SelectNearestRow(opts.nearestRow)
182+ }
178183 rt.pickerMu.Lock()
179184 updated := rt.picker != nil && rt.pickerIntent == intent && rt.pickerSource == source
180185 if updated {
@@ -175,6 +175,11 @@ func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions
175 return175 return
176 }176 }
177 }177 }
178+ // Navigate-only: move intents keep the selection-or-close logic above
179+ // authoritative, so a stale row index can never override it.
180+ if intent == pickerNavigate && opts.nearestRow >= 0 {
181+ model.SelectNearestRow(opts.nearestRow)
182+ }
178 rt.pickerMu.Lock()183 rt.pickerMu.Lock()
179 updated := rt.picker != nil && rt.pickerIntent == intent && rt.pickerSource == source184 updated := rt.picker != nil && rt.pickerIntent == intent && rt.pickerSource == source
180 if updated {185 if updated {
M internal/usecase/daemon/picker.go +4 -1
@@ -225,8 +225,11 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
225225 var ok bool
226226 var intent pickerIntent
227227 var source moveSourceLocator
228+ // prevIdx is the row the victim occupied; -1 means "no post-delete hint".
229+ prevIdx := -1
228230 if result.action == 'x' || result.action == '\r' || result.action == '\n' {
229231 target, ok = rt.picker.Selected()
232+ prevIdx = rt.picker.SelectedIndex()
230233 }
231234 intent, source = rt.pickerIntent, rt.pickerSource
232235 rt.pickerMu.Unlock()
@@ -261,7 +264,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
261264 }
262265 defer fresh.End()
263266 }
264- d.refreshPicker(ac)
267+ d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: prevIdx})
265268 d.invalidateRender(sess, ac, true, "picker.go")
266269 return
267270 }
@@ -225,8 +225,11 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
225 var ok bool225 var ok bool
226 var intent pickerIntent226 var intent pickerIntent
227 var source moveSourceLocator227 var source moveSourceLocator
228+ // prevIdx is the row the victim occupied; -1 means "no post-delete hint".
229+ prevIdx := -1
228 if result.action == 'x' || result.action == '\r' || result.action == '\n' {230 if result.action == 'x' || result.action == '\r' || result.action == '\n' {
229 target, ok = rt.picker.Selected()231 target, ok = rt.picker.Selected()
232+ prevIdx = rt.picker.SelectedIndex()
230 }233 }
231 intent, source = rt.pickerIntent, rt.pickerSource234 intent, source = rt.pickerIntent, rt.pickerSource
232 rt.pickerMu.Unlock()235 rt.pickerMu.Unlock()
@@ -261,7 +264,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
261 }264 }
262 defer fresh.End()265 defer fresh.End()
263 }266 }
264- d.refreshPicker(ac)267+ d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: prevIdx})
265 d.invalidateRender(sess, ac, true, "picker.go")268 d.invalidateRender(sess, ac, true, "picker.go")
266 return269 return
267 }270 }
M internal/usecase/daemon/picker_test.go +81 -42
@@ -1616,51 +1616,90 @@ func TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut(t *t
16161616 require.Same(t, from, ac.currentSession(), "a failed restore must leave the client on its origin session")
16171617 }
16181618
1619-func TestPickerNavigationRefreshAfterSuccessfulDeleteSelectsAttachedActiveStableTab(t *testing.T) {
1620- d, current, ac, _, currentReleases := newManualTabSession(t, 2)
1621- defer releaseAll(currentReleases)
1622- current.id, current.name, current.active = "current", "z-current", 1
1623- current.tabs[0].stableID, current.tabs[1].stableID = "current-first", "current-active"
1624- delete(d.sessions, domain.SessionID("manual"))
1625- d.sessions[current.id] = current
1626-
1627- otherPTY, releaseOther := newBlockingPTY(t)
1628- defer releaseOther()
1629- otherTab := newTestTabWithContext(otherPTY, current.ctx, current.cancel)
1630- otherTab.stableID = "other-tab"
1631- other := &session{id: "other", name: "a-other", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}}
1632- d.sessions[other.id] = other
1633-
1634- targetPTY, releaseTarget := newBlockingPTY(t)
1635- defer releaseTarget()
1636- targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel)
1637- targetTab.stableID = "target-tab"
1638- target := &session{id: "target", name: "m-target", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}
1639- d.sessions[target.id] = target
1640-
1641- d.enterPicker(current, ac)
1642- d.handlePickerInput(ac, []byte("k"))
1643- d.handlePickerInput(ac, []byte("k"))
1644- ac.overlays.pickerMu.Lock()
1645- selected, ok := ac.overlays.picker.Selected()
1646- ac.overlays.pickerMu.Unlock()
1647- require.True(t, ok)
1648- require.Equal(t, target.id, selected.Session, "test must delete the selected non-attached session")
1619+// TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow pins the post-kill
1620+// contract: the cursor stays on the row index the killed session occupied, so
1621+// it lands on whatever session took its place — clamped to the new last
1622+// selectable row when the bottom session was killed. Session names drive the
1623+// picker's alphabetical tie-break (every session here shares mruAt 0), which is
1624+// what puts the victim in the middle or at the end of the list.
1625+func TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow(t *testing.T) {
1626+ tests := []struct {
1627+ name string
1628+ currentName string
1629+ otherName string
1630+ targetName string
1631+ nav []byte // keys walked from the attached session's active tab onto the victim
1632+ wantSession domain.SessionID
1633+ wantTabID domain.TabStableID
1634+ wantTabIndex int
1635+ }{
1636+ {
1637+ // Rows: [a-other, other-tab, m-target, target-tab, z-current, current-first, current-active].
1638+ // Killing target-tab (row 3) promotes z-current's header to row 2, so
1639+ // row 3 becomes current-first — not the attached active tab.
1640+ name: "middle session killed selects the row taking its place",
1641+ currentName: "z-current", otherName: "a-other", targetName: "m-target",
1642+ nav: []byte("kk"),
1643+ wantSession: "current", wantTabID: "current-first", wantTabIndex: 0,
1644+ },
1645+ {
1646+ // Rows: [a-current, current-first, current-active, m-other, other-tab, z-target, target-tab].
1647+ // Killing the bottom row (6) clamps to the new last selectable row.
1648+ name: "last session killed selects the new last row",
1649+ currentName: "a-current", otherName: "m-other", targetName: "z-target",
1650+ nav: []byte("jj"),
1651+ wantSession: "other", wantTabID: "other-tab", wantTabIndex: 0,
1652+ },
1653+ }
1654+ for _, tt := range tests {
1655+ t.Run(tt.name, func(t *testing.T) {
1656+ d, current, ac, _, currentReleases := newManualTabSession(t, 2)
1657+ defer releaseAll(currentReleases)
1658+ current.id, current.name, current.active = "current", tt.currentName, 1
1659+ current.tabs[0].stableID, current.tabs[1].stableID = "current-first", "current-active"
1660+ delete(d.sessions, domain.SessionID("manual"))
1661+ d.sessions[current.id] = current
1662+
1663+ otherPTY, releaseOther := newBlockingPTY(t)
1664+ defer releaseOther()
1665+ otherTab := newTestTabWithContext(otherPTY, current.ctx, current.cancel)
1666+ otherTab.stableID = "other-tab"
1667+ other := &session{id: "other", name: tt.otherName, ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}}
1668+ d.sessions[other.id] = other
1669+
1670+ targetPTY, releaseTarget := newBlockingPTY(t)
1671+ defer releaseTarget()
1672+ targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel)
1673+ targetTab.stableID = "target-tab"
1674+ target := &session{id: "target", name: tt.targetName, ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}
1675+ d.sessions[target.id] = target
1676+
1677+ d.enterPicker(current, ac)
1678+ for _, key := range tt.nav {
1679+ d.handlePickerInput(ac, []byte{key})
1680+ }
1681+ ac.overlays.pickerMu.Lock()
1682+ selected, ok := ac.overlays.picker.Selected()
1683+ ac.overlays.pickerMu.Unlock()
1684+ require.True(t, ok)
1685+ require.Equal(t, target.id, selected.Session, "test must delete the selected non-attached session")
16491686
1650- d.handlePickerInput(ac, []byte("x"))
1687+ d.handlePickerInput(ac, []byte("x"))
16511688
1652- d.mu.Lock()
1653- _, targetStillLive := d.sessions[target.id]
1654- d.mu.Unlock()
1655- require.False(t, targetStillLive, "ordinary picker deletion must remove the selected session")
1689+ d.mu.Lock()
1690+ _, targetStillLive := d.sessions[target.id]
1691+ d.mu.Unlock()
1692+ require.False(t, targetStillLive, "ordinary picker deletion must remove the selected session")
16561693
1657- ac.overlays.pickerMu.Lock()
1658- selected, ok = ac.overlays.picker.Selected()
1659- ac.overlays.pickerMu.Unlock()
1660- require.True(t, ok)
1661- require.Equal(t, current.id, selected.Session)
1662- require.Equal(t, domain.TabStableID("current-active"), selected.TabID)
1663- require.Equal(t, 1, selected.TabIndex)
1694+ ac.overlays.pickerMu.Lock()
1695+ selected, ok = ac.overlays.picker.Selected()
1696+ ac.overlays.pickerMu.Unlock()
1697+ require.True(t, ok)
1698+ require.Equal(t, tt.wantSession, selected.Session)
1699+ require.Equal(t, tt.wantTabID, selected.TabID)
1700+ require.Equal(t, tt.wantTabIndex, selected.TabIndex)
1701+ })
1702+ }
16641703 }
16651704
16661705 func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) {
@@ -1616,51 +1616,90 @@ func TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut(t *t
1616 require.Same(t, from, ac.currentSession(), "a failed restore must leave the client on its origin session")1616 require.Same(t, from, ac.currentSession(), "a failed restore must leave the client on its origin session")
1617 }1617 }
1618 1618
1619-func TestPickerNavigationRefreshAfterSuccessfulDeleteSelectsAttachedActiveStableTab(t *testing.T) {1619+// TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow pins the post-kill
1620- d, current, ac, _, currentReleases := newManualTabSession(t, 2)1620+// contract: the cursor stays on the row index the killed session occupied, so
1621- defer releaseAll(currentReleases)1621+// it lands on whatever session took its place — clamped to the new last
1622- current.id, current.name, current.active = "current", "z-current", 11622+// selectable row when the bottom session was killed. Session names drive the
1623- current.tabs[0].stableID, current.tabs[1].stableID = "current-first", "current-active"1623+// picker's alphabetical tie-break (every session here shares mruAt 0), which is
1624- delete(d.sessions, domain.SessionID("manual"))1624+// what puts the victim in the middle or at the end of the list.
1625- d.sessions[current.id] = current1625+func TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow(t *testing.T) {
1626-1626+ tests := []struct {
1627- otherPTY, releaseOther := newBlockingPTY(t)1627+ name string
1628- defer releaseOther()1628+ currentName string
1629- otherTab := newTestTabWithContext(otherPTY, current.ctx, current.cancel)1629+ otherName string
1630- otherTab.stableID = "other-tab"1630+ targetName string
1631- other := &session{id: "other", name: "a-other", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}}1631+ nav []byte // keys walked from the attached session's active tab onto the victim
1632- d.sessions[other.id] = other1632+ wantSession domain.SessionID
1633-1633+ wantTabID domain.TabStableID
1634- targetPTY, releaseTarget := newBlockingPTY(t)1634+ wantTabIndex int
1635- defer releaseTarget()1635+ }{
1636- targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel)1636+ {
1637- targetTab.stableID = "target-tab"1637+ // Rows: [a-other, other-tab, m-target, target-tab, z-current, current-first, current-active].
1638- target := &session{id: "target", name: "m-target", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}1638+ // Killing target-tab (row 3) promotes z-current's header to row 2, so
1639- d.sessions[target.id] = target1639+ // row 3 becomes current-first — not the attached active tab.
1640-1640+ name: "middle session killed selects the row taking its place",
1641- d.enterPicker(current, ac)1641+ currentName: "z-current", otherName: "a-other", targetName: "m-target",
1642- d.handlePickerInput(ac, []byte("k"))1642+ nav: []byte("kk"),
1643- d.handlePickerInput(ac, []byte("k"))1643+ wantSession: "current", wantTabID: "current-first", wantTabIndex: 0,
1644- ac.overlays.pickerMu.Lock()1644+ },
1645- selected, ok := ac.overlays.picker.Selected()1645+ {
1646- ac.overlays.pickerMu.Unlock()1646+ // Rows: [a-current, current-first, current-active, m-other, other-tab, z-target, target-tab].
1647- require.True(t, ok)1647+ // Killing the bottom row (6) clamps to the new last selectable row.
1648- require.Equal(t, target.id, selected.Session, "test must delete the selected non-attached session")1648+ name: "last session killed selects the new last row",
1649+ currentName: "a-current", otherName: "m-other", targetName: "z-target",
1650+ nav: []byte("jj"),
1651+ wantSession: "other", wantTabID: "other-tab", wantTabIndex: 0,
1652+ },
1653+ }
1654+ for _, tt := range tests {
1655+ t.Run(tt.name, func(t *testing.T) {
1656+ d, current, ac, _, currentReleases := newManualTabSession(t, 2)
1657+ defer releaseAll(currentReleases)
1658+ current.id, current.name, current.active = "current", tt.currentName, 1
1659+ current.tabs[0].stableID, current.tabs[1].stableID = "current-first", "current-active"
1660+ delete(d.sessions, domain.SessionID("manual"))
1661+ d.sessions[current.id] = current
1662+
1663+ otherPTY, releaseOther := newBlockingPTY(t)
1664+ defer releaseOther()
1665+ otherTab := newTestTabWithContext(otherPTY, current.ctx, current.cancel)
1666+ otherTab.stableID = "other-tab"
1667+ other := &session{id: "other", name: tt.otherName, ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}}
1668+ d.sessions[other.id] = other
1669+
1670+ targetPTY, releaseTarget := newBlockingPTY(t)
1671+ defer releaseTarget()
1672+ targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel)
1673+ targetTab.stableID = "target-tab"
1674+ target := &session{id: "target", name: tt.targetName, ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}
1675+ d.sessions[target.id] = target
1676+
1677+ d.enterPicker(current, ac)
1678+ for _, key := range tt.nav {
1679+ d.handlePickerInput(ac, []byte{key})
1680+ }
1681+ ac.overlays.pickerMu.Lock()
1682+ selected, ok := ac.overlays.picker.Selected()
1683+ ac.overlays.pickerMu.Unlock()
1684+ require.True(t, ok)
1685+ require.Equal(t, target.id, selected.Session, "test must delete the selected non-attached session")
1649 1686
1650- d.handlePickerInput(ac, []byte("x"))1687+ d.handlePickerInput(ac, []byte("x"))
1651 1688
1652- d.mu.Lock()1689+ d.mu.Lock()
1653- _, targetStillLive := d.sessions[target.id]1690+ _, targetStillLive := d.sessions[target.id]
1654- d.mu.Unlock()1691+ d.mu.Unlock()
1655- require.False(t, targetStillLive, "ordinary picker deletion must remove the selected session")1692+ require.False(t, targetStillLive, "ordinary picker deletion must remove the selected session")
1656 1693
1657- ac.overlays.pickerMu.Lock()1694+ ac.overlays.pickerMu.Lock()
1658- selected, ok = ac.overlays.picker.Selected()1695+ selected, ok = ac.overlays.picker.Selected()
1659- ac.overlays.pickerMu.Unlock()1696+ ac.overlays.pickerMu.Unlock()
1660- require.True(t, ok)1697+ require.True(t, ok)
1661- require.Equal(t, current.id, selected.Session)1698+ require.Equal(t, tt.wantSession, selected.Session)
1662- require.Equal(t, domain.TabStableID("current-active"), selected.TabID)1699+ require.Equal(t, tt.wantTabID, selected.TabID)
1663- require.Equal(t, 1, selected.TabIndex)1700+ require.Equal(t, tt.wantTabIndex, selected.TabIndex)
1701+ })
1702+ }
1664 }1703 }
1665 1704
1666 func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) {1705 func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) {
M internal/usecase/picker/model.go +31 -0
@@ -327,6 +327,37 @@ func (m *Model) Selected() (Target, bool) {
327327 }, true
328328 }
329329
330+// SelectedIndex reports the raw selected row index, -1 when nothing is
331+// selected. Row indices are only meaningful against this exact model.
332+func (m *Model) SelectedIndex() int {
333+ if m == nil {
334+ return -1
335+ }
336+ return m.selected
337+}
338+
339+// SelectNearestRow selects the first selectable row at or after idx, falling
340+// back to the last selectable row before it. Callers use it to keep the
341+// cursor on the row that takes a removed item's place.
342+func (m *Model) SelectNearestRow(idx int) {
343+ if m == nil || len(m.rows) == 0 {
344+ return
345+ }
346+ idx = clamp(idx, 0, len(m.rows)-1)
347+ for i := idx; i < len(m.rows); i++ {
348+ if m.rows[i].kind.selectable(m.mode) {
349+ m.selected = i
350+ return
351+ }
352+ }
353+ for i := idx - 1; i >= 0; i-- {
354+ if m.rows[i].kind.selectable(m.mode) {
355+ m.selected = i
356+ return
357+ }
358+ }
359+}
360+
330361 func (m *Model) Clone() *Model {
331362 if m == nil {
332363 return nil
@@ -327,6 +327,37 @@ func (m *Model) Selected() (Target, bool) {
327 }, true327 }, true
328 }328 }
329 329
330+// SelectedIndex reports the raw selected row index, -1 when nothing is
331+// selected. Row indices are only meaningful against this exact model.
332+func (m *Model) SelectedIndex() int {
333+ if m == nil {
334+ return -1
335+ }
336+ return m.selected
337+}
338+
339+// SelectNearestRow selects the first selectable row at or after idx, falling
340+// back to the last selectable row before it. Callers use it to keep the
341+// cursor on the row that takes a removed item's place.
342+func (m *Model) SelectNearestRow(idx int) {
343+ if m == nil || len(m.rows) == 0 {
344+ return
345+ }
346+ idx = clamp(idx, 0, len(m.rows)-1)
347+ for i := idx; i < len(m.rows); i++ {
348+ if m.rows[i].kind.selectable(m.mode) {
349+ m.selected = i
350+ return
351+ }
352+ }
353+ for i := idx - 1; i >= 0; i-- {
354+ if m.rows[i].kind.selectable(m.mode) {
355+ m.selected = i
356+ return
357+ }
358+ }
359+}
360+
330 func (m *Model) Clone() *Model {361 func (m *Model) Clone() *Model {
331 if m == nil {362 if m == nil {
332 return nil363 return nil
M internal/usecase/picker/model_test.go +64 -0
@@ -406,6 +406,70 @@ func TestSelectedMapping(t *testing.T) {
406406 require.Equal(t, Target{Session: "beta", TabID: "three", TabIndex: 1}, got)
407407 }
408408
409+func TestSelectNearestRow(t *testing.T) {
410+ // Three single-tab sessions: rows are [hdrA, tabA, hdrB, tabB, hdrC, tabC].
411+ sessions := []SessionView{
412+ {ID: "a", Name: "a", Tabs: []TabEntry{{TabID: "ta", Name: "ta"}}},
413+ {ID: "b", Name: "b", Tabs: []TabEntry{{TabID: "tb", Name: "tb"}}},
414+ {ID: "c", Name: "c", Tabs: []TabEntry{{TabID: "tc", Name: "tc"}}},
415+ }
416+ tests := []struct {
417+ name string
418+ idx int
419+ want domain.SessionID
420+ }{
421+ {name: "exact selectable row", idx: 3, want: "b"},
422+ {name: "header row snaps to its tab", idx: 2, want: "b"},
423+ {name: "past end clamps to last selectable", idx: 40, want: "c"},
424+ {name: "negative clamps to first selectable", idx: -2, want: "a"},
425+ {name: "last row is a tab and stays put", idx: 5, want: "c"},
426+ }
427+ for _, tc := range tests {
428+ t.Run(tc.name, func(t *testing.T) {
429+ m := New(sessions, SelectionConfig{Mode: SelectNavigationTab})
430+ m.SelectNearestRow(tc.idx)
431+ target, ok := m.Selected()
432+ require.True(t, ok)
433+ require.Equal(t, tc.want, target.Session)
434+ })
435+ }
436+}
437+
438+func TestSelectNearestRowFallsBackBackwardAndToleratesEmptyModels(t *testing.T) {
439+ // Move-tab mode makes only session headers selectable, so the trailing tab
440+ // row has no selectable row at or after it.
441+ m := New([]SessionView{
442+ {ID: "a", Name: "a", Tabs: []TabEntry{{TabID: "ta", Name: "ta"}}},
443+ {ID: "b", Name: "b", Tabs: []TabEntry{{TabID: "tb", Name: "tb"}}},
444+ }, SelectionConfig{Mode: SelectMoveTabSession, Source: SourceFilter{Session: "zzz"}})
445+ require.Equal(t, 0, m.SelectedIndex(), "guard: the backward fallback below must move the selection off this starting row")
446+ m.SelectNearestRow(3)
447+ target, ok := m.Selected()
448+ require.True(t, ok)
449+ require.Equal(t, domain.SessionID("b"), target.Session)
450+ require.Equal(t, 2, m.SelectedIndex())
451+
452+ empty := New(nil, SelectionConfig{Mode: SelectNavigationTab})
453+ empty.SelectNearestRow(0)
454+ _, ok = empty.Selected()
455+ require.False(t, ok)
456+ require.Equal(t, -1, empty.SelectedIndex())
457+
458+ var nilModel *Model
459+ nilModel.SelectNearestRow(3)
460+ require.Equal(t, -1, nilModel.SelectedIndex())
461+}
462+
463+func TestSelectedIndexReportsRawSelectedRow(t *testing.T) {
464+ m := New([]SessionView{
465+ {ID: "alpha", Name: "alpha", Tabs: []TabEntry{{TabID: "one", Name: "one"}, {TabID: "two", Name: "two"}}},
466+ }, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "alpha", TabID: "two"}})
467+
468+ require.Equal(t, 2, m.SelectedIndex())
469+ m.Up()
470+ require.Equal(t, 1, m.SelectedIndex())
471+}
472+
409473 func TestStoppedSessionSelectableAndRendered(t *testing.T) {
410474 m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, SelectionConfig{Mode: SelectNavigationTab})
411475 got, ok := m.Selected()
@@ -406,6 +406,70 @@ func TestSelectedMapping(t *testing.T) {
406 require.Equal(t, Target{Session: "beta", TabID: "three", TabIndex: 1}, got)406 require.Equal(t, Target{Session: "beta", TabID: "three", TabIndex: 1}, got)
407 }407 }
408 408
409+func TestSelectNearestRow(t *testing.T) {
410+ // Three single-tab sessions: rows are [hdrA, tabA, hdrB, tabB, hdrC, tabC].
411+ sessions := []SessionView{
412+ {ID: "a", Name: "a", Tabs: []TabEntry{{TabID: "ta", Name: "ta"}}},
413+ {ID: "b", Name: "b", Tabs: []TabEntry{{TabID: "tb", Name: "tb"}}},
414+ {ID: "c", Name: "c", Tabs: []TabEntry{{TabID: "tc", Name: "tc"}}},
415+ }
416+ tests := []struct {
417+ name string
418+ idx int
419+ want domain.SessionID
420+ }{
421+ {name: "exact selectable row", idx: 3, want: "b"},
422+ {name: "header row snaps to its tab", idx: 2, want: "b"},
423+ {name: "past end clamps to last selectable", idx: 40, want: "c"},
424+ {name: "negative clamps to first selectable", idx: -2, want: "a"},
425+ {name: "last row is a tab and stays put", idx: 5, want: "c"},
426+ }
427+ for _, tc := range tests {
428+ t.Run(tc.name, func(t *testing.T) {
429+ m := New(sessions, SelectionConfig{Mode: SelectNavigationTab})
430+ m.SelectNearestRow(tc.idx)
431+ target, ok := m.Selected()
432+ require.True(t, ok)
433+ require.Equal(t, tc.want, target.Session)
434+ })
435+ }
436+}
437+
438+func TestSelectNearestRowFallsBackBackwardAndToleratesEmptyModels(t *testing.T) {
439+ // Move-tab mode makes only session headers selectable, so the trailing tab
440+ // row has no selectable row at or after it.
441+ m := New([]SessionView{
442+ {ID: "a", Name: "a", Tabs: []TabEntry{{TabID: "ta", Name: "ta"}}},
443+ {ID: "b", Name: "b", Tabs: []TabEntry{{TabID: "tb", Name: "tb"}}},
444+ }, SelectionConfig{Mode: SelectMoveTabSession, Source: SourceFilter{Session: "zzz"}})
445+ require.Equal(t, 0, m.SelectedIndex(), "guard: the backward fallback below must move the selection off this starting row")
446+ m.SelectNearestRow(3)
447+ target, ok := m.Selected()
448+ require.True(t, ok)
449+ require.Equal(t, domain.SessionID("b"), target.Session)
450+ require.Equal(t, 2, m.SelectedIndex())
451+
452+ empty := New(nil, SelectionConfig{Mode: SelectNavigationTab})
453+ empty.SelectNearestRow(0)
454+ _, ok = empty.Selected()
455+ require.False(t, ok)
456+ require.Equal(t, -1, empty.SelectedIndex())
457+
458+ var nilModel *Model
459+ nilModel.SelectNearestRow(3)
460+ require.Equal(t, -1, nilModel.SelectedIndex())
461+}
462+
463+func TestSelectedIndexReportsRawSelectedRow(t *testing.T) {
464+ m := New([]SessionView{
465+ {ID: "alpha", Name: "alpha", Tabs: []TabEntry{{TabID: "one", Name: "one"}, {TabID: "two", Name: "two"}}},
466+ }, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "alpha", TabID: "two"}})
467+
468+ require.Equal(t, 2, m.SelectedIndex())
469+ m.Up()
470+ require.Equal(t, 1, m.SelectedIndex())
471+}
472+
409 func TestStoppedSessionSelectableAndRendered(t *testing.T) {473 func TestStoppedSessionSelectableAndRendered(t *testing.T) {
410 m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, SelectionConfig{Mode: SelectNavigationTab})474 m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, SelectionConfig{Mode: SelectNavigationTab})
411 got, ok := m.Selected()475 got, ok := m.Selected()

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