detached ffcb75d

refactor(picker): support move destination modes

commit
ffcb75d7604f58a469d0c2dbd2c72195cd6d77c4
author
brice · · 2026-07-28 02:06 UTC
parent
707a1cd
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +955 -107
M internal/domain/types.go +7 -1
@@ -92,5 +92,11 @@ type Rect struct {
9292 // SessionID uniquely identifies a session.
9393 type SessionID string
9494
95-// TabID uniquely identifies a tab within a session.
95+// TabID identifies a tab by its session-local, user-facing ID.
9696 type TabID string
97+
98+// TabStableID identifies a tab independently of its mutable session position.
99+type TabStableID string
100+
101+// PaneStableID identifies a pane independently of its layout-local PaneID.
102+type PaneStableID string
@@ -92,5 +92,11 @@ type Rect struct {
92 // SessionID uniquely identifies a session.92 // SessionID uniquely identifies a session.
93 type SessionID string93 type SessionID string
94 94
95-// TabID uniquely identifies a tab within a session.95+// TabID identifies a tab by its session-local, user-facing ID.
96 type TabID string96 type TabID string
97+
98+// TabStableID identifies a tab independently of its mutable session position.
99+type TabStableID string
100+
101+// PaneStableID identifies a pane independently of its layout-local PaneID.
102+type PaneStableID string
M internal/usecase/daemon/overlay_runtime.go +4 -0
@@ -18,6 +18,8 @@ type overlayRuntime struct {
1818
1919 pickerMu sync.Mutex
2020 picker *picker.Model
21+ pickerIntent pickerIntent
22+ pickerSource moveSourceLocator
2123 pickerPreview *tab
2224 pickerPreviewSession *session
2325 pickerPreviewGeneration uint64
@@ -255,6 +257,8 @@ func (d *Daemon) clearForSnatch(token attachmentRoleToken) bool {
255257 previewSession := rt.pickerPreviewSession
256258 previewGeneration := rt.pickerPreviewGeneration
257259 rt.picker = nil
260+ rt.pickerIntent = pickerNavigate
261+ rt.pickerSource = moveSourceLocator{}
258262 rt.pickerPending = nil
259263 rt.pickerESC.stop()
260264 rt.pickerPreviewGeneration++
@@ -18,6 +18,8 @@ type overlayRuntime struct {
18 18
19 pickerMu sync.Mutex19 pickerMu sync.Mutex
20 picker *picker.Model20 picker *picker.Model
21+ pickerIntent pickerIntent
22+ pickerSource moveSourceLocator
21 pickerPreview *tab23 pickerPreview *tab
22 pickerPreviewSession *session24 pickerPreviewSession *session
23 pickerPreviewGeneration uint6425 pickerPreviewGeneration uint64
@@ -255,6 +257,8 @@ func (d *Daemon) clearForSnatch(token attachmentRoleToken) bool {
255 previewSession := rt.pickerPreviewSession257 previewSession := rt.pickerPreviewSession
256 previewGeneration := rt.pickerPreviewGeneration258 previewGeneration := rt.pickerPreviewGeneration
257 rt.picker = nil259 rt.picker = nil
260+ rt.pickerIntent = pickerNavigate
261+ rt.pickerSource = moveSourceLocator{}
258 rt.pickerPending = nil262 rt.pickerPending = nil
259 rt.pickerESC.stop()263 rt.pickerESC.stop()
260 rt.pickerPreviewGeneration++264 rt.pickerPreviewGeneration++
M internal/usecase/daemon/picker.go +169 -31
@@ -14,20 +14,73 @@ import (
1414 "github.com/bnema/vev/pkg/renderer"
1515 )
1616
17-var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}}
17+var (
18+ pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}}
19+ errNoMoveDestination = errors.New("no eligible move destination")
20+)
21+
22+type pickerIntent uint8
1823
24+const (
25+ pickerNavigate pickerIntent = iota
26+ pickerMovePane
27+ pickerMoveTab
28+)
29+
30+type moveSessionLocator struct {
31+ ID domain.SessionID
32+ Incarnation domain.IncarnationID
33+ Name string
34+}
35+
36+type moveSourceLocator struct {
37+ Session moveSessionLocator
38+ TabID domain.TabStableID
39+ PaneID domain.PaneStableID
40+}
41+
42+// enterPicker preserves the existing navigation entry point. Navigation always
43+// publishes its model, including an empty one; only move entry can fail for a
44+// missing destination.
1945 func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
20- views, curTab := d.pickerViews(sess)
21- ac.overlays.pickerMu.Lock()
22- ac.overlays.picker = picker.New(views, sess.id, curTab)
23- ac.overlays.pickerPending = nil
24- ac.overlays.pickerPreview = nil
25- ac.overlays.pickerMu.Unlock()
46+ model := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
47+ d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
48+}
49+
50+// enterPickerForIntent returns errNoMoveDestination only for move intents.
51+func (d *Daemon) enterPickerForIntent(sess *session, ac *attachedClient, intent pickerIntent, source moveSourceLocator) error {
52+ model := d.newPickerModel(sess, intent, source, picker.SourceFilter{})
53+ if intent != pickerNavigate {
54+ if _, ok := model.Selected(); !ok {
55+ return errNoMoveDestination
56+ }
57+ }
58+
59+ d.publishPicker(sess, ac, model, intent, source)
60+ return nil
61+}
62+
63+func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.Model, intent pickerIntent, source moveSourceLocator) {
64+ rt := ac.overlays
65+ rt.pickerMu.Lock()
66+ previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration
67+ rt.pickerPreviewGeneration++
68+ rt.pickerPreviewSession = nil
69+ rt.pickerPreview = nil
70+ rt.picker = model
71+ rt.pickerIntent = intent
72+ rt.pickerSource = source
73+ rt.pickerPending = nil
74+ rt.pickerESC.stop()
75+ rt.pickerMu.Unlock()
76+ d.teardownPreviewSubscription(ac, previous, previousGeneration)
2677 d.registerPreviewForSelection(ac)
2778 d.invalidateRender(sess, ac, true, "picker.go")
2879 }
2980
30-func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
81+// pickerViews captures one canonical lifecycle/tab snapshot. It intentionally
82+// knows nothing about picker intent; picker.New owns all destination policy.
83+func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceFilter) {
3184 d.mu.Lock()
3285 sessions := d.sessionsSnapshotLocked()
3386 stopped := make([]stoppedSession, 0, len(d.stopped))
@@ -46,33 +99,37 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
4699
47100 includeTerminalTitle := d.currentTabsConfig().TerminalTitle
48101 views := make([]picker.SessionView, 0, len(sessions)+len(stopped))
49- curTab := 0
102+ var current picker.SourceFilter
50103 for _, s := range sessions {
51104 s.mu.Lock()
52- view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))}
105+ view := picker.SessionView{
106+ ID: s.id, Incarnation: s.incarnation, Name: s.name, TargetName: s.name, Active: s.active,
107+ Tabs: make([]picker.TabEntry, 0, len(s.tabs)),
108+ }
53109 if !s.ephemeral {
54110 createdAt := s.createdAt
55- view.TargetName = s.name
56111 view.ExpectedCreatedAt = &createdAt
57112 }
58113 sessionAttention := false
59114 for i, tb := range s.tabs {
60115 name := tabDisplayName(tb, i)
61- view.Tabs[i] = picker.TabEntry{
116+ tabID := domain.TabStableID(tb.stableID)
117+ view.Tabs = append(view.Tabs, picker.TabEntry{
118+ TabID: tabID,
62119 Name: name,
63120 Detail: tabTitleDetail(name, tb.focusedPaneTitle(includeTerminalTitle)),
64121 Attention: tb.attention,
65- }
122+ })
66123 if tb.attention {
67124 sessionAttention = true
68125 }
126+ if s == cur && i == s.active {
127+ current = picker.SourceFilter{Session: s.id, Incarnation: s.incarnation, TabID: tabID}
128+ }
69129 }
70130 if sessionAttention {
71131 view.Name = attentionSuffix(view.Name)
72132 }
73- if s == cur {
74- curTab = s.active
75- }
76133 s.mu.Unlock()
77134 views = append(views, view)
78135 }
@@ -80,6 +137,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
80137 createdAt := s.createdAt
81138 views = append(views, picker.SessionView{
82139 ID: domain.SessionID("stopped:" + s.name),
140+ Incarnation: s.incarnation,
83141 Name: s.name,
84142 TargetName: s.name,
85143 Tabs: []picker.TabEntry{{}},
@@ -87,7 +145,32 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
87145 ExpectedCreatedAt: &createdAt,
88146 })
89147 }
90- return views, curTab
148+ return views, current
149+}
150+
151+func (d *Daemon) newPickerModel(cur *session, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model {
152+ views, attachedCurrent := d.pickerViews(cur)
153+ if intent == pickerNavigate {
154+ current = attachedCurrent
155+ }
156+ return picker.New(views, picker.SelectionConfig{
157+ Mode: pickerSelectionMode(intent),
158+ Current: current,
159+ Source: picker.SourceFilter{
160+ Session: source.Session.ID, Incarnation: source.Session.Incarnation, TabID: source.TabID,
161+ },
162+ })
163+}
164+
165+func pickerSelectionMode(intent pickerIntent) picker.SelectionMode {
166+ switch intent {
167+ case pickerMovePane:
168+ return picker.SelectMovePaneTab
169+ case pickerMoveTab:
170+ return picker.SelectMoveTabSession
171+ default:
172+ return picker.SelectNavigationTab
173+ }
91174 }
92175
93176 func attentionSuffix(label string) string {
@@ -107,6 +190,8 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
107190 active: func() bool { return rt.picker != nil },
108191 closeLocked: func() {
109192 rt.picker = nil
193+ rt.pickerIntent = pickerNavigate
194+ rt.pickerSource = moveSourceLocator{}
110195 generation = rt.pickerPreviewGeneration
111196 },
112197 afterClose: func() {
@@ -206,6 +291,7 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
206291 ac.overlays.pickerMu.Lock()
207292 var target picker.Target
208293 var ok bool
294+ intent := ac.overlays.pickerIntent
209295 if ac.overlays.picker != nil {
210296 target, ok = ac.overlays.picker.Selected()
211297 }
@@ -219,15 +305,14 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
219305 if !ok {
220306 return
221307 }
222- next := d.tabByTarget(target)
223- targetSess := d.sessionByID(target.Session)
308+ targetSess, next := d.previewTarget(target, intent)
224309 if next == nil || targetSess == nil {
225310 return
226311 }
227312 ac.overlays.pickerMu.Lock()
228313 // Selection may have changed while the target was resolved.
229314 selected, stillSelected := ac.overlays.picker.Selected()
230- valid := ac.overlays.pickerPreviewGeneration == generation && stillSelected && selected == target
315+ valid := ac.overlays.pickerPreviewGeneration == generation && ac.overlays.pickerIntent == intent && stillSelected && pickerTargetsEqual(selected, target)
231316 if valid {
232317 ac.overlays.pickerPreview = next
233318 ac.overlays.pickerPreviewSession = targetSess
@@ -339,6 +424,8 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
339424 func (d *Daemon) closePicker(ac *attachedClient) {
340425 ac.overlays.pickerMu.Lock()
341426 ac.overlays.picker = nil
427+ ac.overlays.pickerIntent = pickerNavigate
428+ ac.overlays.pickerSource = moveSourceLocator{}
342429 ac.overlays.pickerPending = nil
343430 ac.overlays.pickerESC.stop()
344431 generation := ac.overlays.pickerPreviewGeneration
@@ -346,19 +433,48 @@ func (d *Daemon) closePicker(ac *attachedClient) {
346433 d.clearPreviewGeneration(ac, generation)
347434 }
348435
349-func (d *Daemon) tabByTarget(target picker.Target) *tab {
436+func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*session, *tab) {
350437 d.mu.Lock()
351438 sess := d.sessions[target.Session]
352- d.mu.Unlock()
353439 if sess == nil {
354- return nil
440+ d.mu.Unlock()
441+ return nil, nil
355442 }
356443 sess.mu.Lock()
444+ d.mu.Unlock()
357445 defer sess.mu.Unlock()
446+ if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt) {
447+ return nil, nil
448+ }
449+ if intent == pickerMoveTab {
450+ if sess.active < 0 || sess.active >= len(sess.tabs) {
451+ return nil, nil
452+ }
453+ return sess, sess.tabs[sess.active]
454+ }
455+ if target.TabID != "" {
456+ for _, tb := range sess.tabs {
457+ if domain.TabStableID(tb.stableID) == target.TabID {
458+ return sess, tb
459+ }
460+ }
461+ return nil, nil
462+ }
358463 if target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) {
359- return nil
464+ return nil, nil
360465 }
361- return sess.tabs[target.TabIndex]
466+ return sess, sess.tabs[target.TabIndex]
467+}
468+
469+func pickerTargetsEqual(left, right picker.Target) bool {
470+ if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name ||
471+ left.TabID != right.TabID || left.TabIndex != right.TabIndex || left.Stopped != right.Stopped {
472+ return false
473+ }
474+ if left.ExpectedCreatedAt == nil || right.ExpectedCreatedAt == nil {
475+ return left.ExpectedCreatedAt == nil && right.ExpectedCreatedAt == nil
476+ }
477+ return *left.ExpectedCreatedAt == *right.ExpectedCreatedAt
362478 }
363479
364480 func (d *Daemon) sessionByID(id domain.SessionID) *session {
@@ -770,13 +886,35 @@ func (d *Daemon) refreshPicker(ac *attachedClient) {
770886 if sess == nil {
771887 return
772888 }
773- views, curTab := d.pickerViews(sess)
774- ac.overlays.pickerMu.Lock()
775- if ac.overlays.picker != nil {
776- ac.overlays.picker = picker.New(views, sess.id, curTab)
889+ rt := ac.overlays
890+ rt.pickerMu.Lock()
891+ if rt.picker == nil {
892+ rt.pickerMu.Unlock()
893+ return
894+ }
895+ intent, source := rt.pickerIntent, rt.pickerSource
896+ current := picker.SourceFilter{}
897+ if intent != pickerNavigate {
898+ selected, _ := rt.picker.Selected()
899+ current = picker.SourceFilter{Session: selected.Session, Incarnation: selected.Incarnation, TabID: selected.TabID}
900+ }
901+ rt.pickerMu.Unlock()
902+ model := d.newPickerModel(sess, intent, source, current)
903+ if intent != pickerNavigate {
904+ if _, ok := model.Selected(); !ok {
905+ d.closePicker(ac)
906+ return
907+ }
908+ }
909+ rt.pickerMu.Lock()
910+ updated := rt.picker != nil && rt.pickerIntent == intent && rt.pickerSource == source
911+ if updated {
912+ rt.picker = model
913+ }
914+ rt.pickerMu.Unlock()
915+ if updated {
916+ d.registerPreviewForSelection(ac)
777917 }
778- ac.overlays.pickerMu.Unlock()
779- d.registerPreviewForSelection(ac)
780918 }
781919
782920 func snapshotPickerPreview(tb *tab) picker.Preview {
@@ -14,20 +14,73 @@ import (
14 "github.com/bnema/vev/pkg/renderer"14 "github.com/bnema/vev/pkg/renderer"
15 )15 )
16 16
17-var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}}17+var (
18+ pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}}
19+ errNoMoveDestination = errors.New("no eligible move destination")
20+)
21+
22+type pickerIntent uint8
18 23
24+const (
25+ pickerNavigate pickerIntent = iota
26+ pickerMovePane
27+ pickerMoveTab
28+)
29+
30+type moveSessionLocator struct {
31+ ID domain.SessionID
32+ Incarnation domain.IncarnationID
33+ Name string
34+}
35+
36+type moveSourceLocator struct {
37+ Session moveSessionLocator
38+ TabID domain.TabStableID
39+ PaneID domain.PaneStableID
40+}
41+
42+// enterPicker preserves the existing navigation entry point. Navigation always
43+// publishes its model, including an empty one; only move entry can fail for a
44+// missing destination.
19 func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {45 func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
20- views, curTab := d.pickerViews(sess)46+ model := d.newPickerModel(sess, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
21- ac.overlays.pickerMu.Lock()47+ d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
22- ac.overlays.picker = picker.New(views, sess.id, curTab)48+}
23- ac.overlays.pickerPending = nil49+
24- ac.overlays.pickerPreview = nil50+// enterPickerForIntent returns errNoMoveDestination only for move intents.
25- ac.overlays.pickerMu.Unlock()51+func (d *Daemon) enterPickerForIntent(sess *session, ac *attachedClient, intent pickerIntent, source moveSourceLocator) error {
52+ model := d.newPickerModel(sess, intent, source, picker.SourceFilter{})
53+ if intent != pickerNavigate {
54+ if _, ok := model.Selected(); !ok {
55+ return errNoMoveDestination
56+ }
57+ }
58+
59+ d.publishPicker(sess, ac, model, intent, source)
60+ return nil
61+}
62+
63+func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.Model, intent pickerIntent, source moveSourceLocator) {
64+ rt := ac.overlays
65+ rt.pickerMu.Lock()
66+ previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration
67+ rt.pickerPreviewGeneration++
68+ rt.pickerPreviewSession = nil
69+ rt.pickerPreview = nil
70+ rt.picker = model
71+ rt.pickerIntent = intent
72+ rt.pickerSource = source
73+ rt.pickerPending = nil
74+ rt.pickerESC.stop()
75+ rt.pickerMu.Unlock()
76+ d.teardownPreviewSubscription(ac, previous, previousGeneration)
26 d.registerPreviewForSelection(ac)77 d.registerPreviewForSelection(ac)
27 d.invalidateRender(sess, ac, true, "picker.go")78 d.invalidateRender(sess, ac, true, "picker.go")
28 }79 }
29 80
30-func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {81+// pickerViews captures one canonical lifecycle/tab snapshot. It intentionally
82+// knows nothing about picker intent; picker.New owns all destination policy.
83+func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceFilter) {
31 d.mu.Lock()84 d.mu.Lock()
32 sessions := d.sessionsSnapshotLocked()85 sessions := d.sessionsSnapshotLocked()
33 stopped := make([]stoppedSession, 0, len(d.stopped))86 stopped := make([]stoppedSession, 0, len(d.stopped))
@@ -46,33 +99,37 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
46 99
47 includeTerminalTitle := d.currentTabsConfig().TerminalTitle100 includeTerminalTitle := d.currentTabsConfig().TerminalTitle
48 views := make([]picker.SessionView, 0, len(sessions)+len(stopped))101 views := make([]picker.SessionView, 0, len(sessions)+len(stopped))
49- curTab := 0102+ var current picker.SourceFilter
50 for _, s := range sessions {103 for _, s := range sessions {
51 s.mu.Lock()104 s.mu.Lock()
52- view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))}105+ view := picker.SessionView{
106+ ID: s.id, Incarnation: s.incarnation, Name: s.name, TargetName: s.name, Active: s.active,
107+ Tabs: make([]picker.TabEntry, 0, len(s.tabs)),
108+ }
53 if !s.ephemeral {109 if !s.ephemeral {
54 createdAt := s.createdAt110 createdAt := s.createdAt
55- view.TargetName = s.name
56 view.ExpectedCreatedAt = &createdAt111 view.ExpectedCreatedAt = &createdAt
57 }112 }
58 sessionAttention := false113 sessionAttention := false
59 for i, tb := range s.tabs {114 for i, tb := range s.tabs {
60 name := tabDisplayName(tb, i)115 name := tabDisplayName(tb, i)
61- view.Tabs[i] = picker.TabEntry{116+ tabID := domain.TabStableID(tb.stableID)
117+ view.Tabs = append(view.Tabs, picker.TabEntry{
118+ TabID: tabID,
62 Name: name,119 Name: name,
63 Detail: tabTitleDetail(name, tb.focusedPaneTitle(includeTerminalTitle)),120 Detail: tabTitleDetail(name, tb.focusedPaneTitle(includeTerminalTitle)),
64 Attention: tb.attention,121 Attention: tb.attention,
65- }122+ })
66 if tb.attention {123 if tb.attention {
67 sessionAttention = true124 sessionAttention = true
68 }125 }
126+ if s == cur && i == s.active {
127+ current = picker.SourceFilter{Session: s.id, Incarnation: s.incarnation, TabID: tabID}
128+ }
69 }129 }
70 if sessionAttention {130 if sessionAttention {
71 view.Name = attentionSuffix(view.Name)131 view.Name = attentionSuffix(view.Name)
72 }132 }
73- if s == cur {
74- curTab = s.active
75- }
76 s.mu.Unlock()133 s.mu.Unlock()
77 views = append(views, view)134 views = append(views, view)
78 }135 }
@@ -80,6 +137,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
80 createdAt := s.createdAt137 createdAt := s.createdAt
81 views = append(views, picker.SessionView{138 views = append(views, picker.SessionView{
82 ID: domain.SessionID("stopped:" + s.name),139 ID: domain.SessionID("stopped:" + s.name),
140+ Incarnation: s.incarnation,
83 Name: s.name,141 Name: s.name,
84 TargetName: s.name,142 TargetName: s.name,
85 Tabs: []picker.TabEntry{{}},143 Tabs: []picker.TabEntry{{}},
@@ -87,7 +145,32 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
87 ExpectedCreatedAt: &createdAt,145 ExpectedCreatedAt: &createdAt,
88 })146 })
89 }147 }
90- return views, curTab148+ return views, current
149+}
150+
151+func (d *Daemon) newPickerModel(cur *session, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model {
152+ views, attachedCurrent := d.pickerViews(cur)
153+ if intent == pickerNavigate {
154+ current = attachedCurrent
155+ }
156+ return picker.New(views, picker.SelectionConfig{
157+ Mode: pickerSelectionMode(intent),
158+ Current: current,
159+ Source: picker.SourceFilter{
160+ Session: source.Session.ID, Incarnation: source.Session.Incarnation, TabID: source.TabID,
161+ },
162+ })
163+}
164+
165+func pickerSelectionMode(intent pickerIntent) picker.SelectionMode {
166+ switch intent {
167+ case pickerMovePane:
168+ return picker.SelectMovePaneTab
169+ case pickerMoveTab:
170+ return picker.SelectMoveTabSession
171+ default:
172+ return picker.SelectNavigationTab
173+ }
91 }174 }
92 175
93 func attentionSuffix(label string) string {176 func attentionSuffix(label string) string {
@@ -107,6 +190,8 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
107 active: func() bool { return rt.picker != nil },190 active: func() bool { return rt.picker != nil },
108 closeLocked: func() {191 closeLocked: func() {
109 rt.picker = nil192 rt.picker = nil
193+ rt.pickerIntent = pickerNavigate
194+ rt.pickerSource = moveSourceLocator{}
110 generation = rt.pickerPreviewGeneration195 generation = rt.pickerPreviewGeneration
111 },196 },
112 afterClose: func() {197 afterClose: func() {
@@ -206,6 +291,7 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
206 ac.overlays.pickerMu.Lock()291 ac.overlays.pickerMu.Lock()
207 var target picker.Target292 var target picker.Target
208 var ok bool293 var ok bool
294+ intent := ac.overlays.pickerIntent
209 if ac.overlays.picker != nil {295 if ac.overlays.picker != nil {
210 target, ok = ac.overlays.picker.Selected()296 target, ok = ac.overlays.picker.Selected()
211 }297 }
@@ -219,15 +305,14 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
219 if !ok {305 if !ok {
220 return306 return
221 }307 }
222- next := d.tabByTarget(target)308+ targetSess, next := d.previewTarget(target, intent)
223- targetSess := d.sessionByID(target.Session)
224 if next == nil || targetSess == nil {309 if next == nil || targetSess == nil {
225 return310 return
226 }311 }
227 ac.overlays.pickerMu.Lock()312 ac.overlays.pickerMu.Lock()
228 // Selection may have changed while the target was resolved.313 // Selection may have changed while the target was resolved.
229 selected, stillSelected := ac.overlays.picker.Selected()314 selected, stillSelected := ac.overlays.picker.Selected()
230- valid := ac.overlays.pickerPreviewGeneration == generation && stillSelected && selected == target315+ valid := ac.overlays.pickerPreviewGeneration == generation && ac.overlays.pickerIntent == intent && stillSelected && pickerTargetsEqual(selected, target)
231 if valid {316 if valid {
232 ac.overlays.pickerPreview = next317 ac.overlays.pickerPreview = next
233 ac.overlays.pickerPreviewSession = targetSess318 ac.overlays.pickerPreviewSession = targetSess
@@ -339,6 +424,8 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
339 func (d *Daemon) closePicker(ac *attachedClient) {424 func (d *Daemon) closePicker(ac *attachedClient) {
340 ac.overlays.pickerMu.Lock()425 ac.overlays.pickerMu.Lock()
341 ac.overlays.picker = nil426 ac.overlays.picker = nil
427+ ac.overlays.pickerIntent = pickerNavigate
428+ ac.overlays.pickerSource = moveSourceLocator{}
342 ac.overlays.pickerPending = nil429 ac.overlays.pickerPending = nil
343 ac.overlays.pickerESC.stop()430 ac.overlays.pickerESC.stop()
344 generation := ac.overlays.pickerPreviewGeneration431 generation := ac.overlays.pickerPreviewGeneration
@@ -346,19 +433,48 @@ func (d *Daemon) closePicker(ac *attachedClient) {
346 d.clearPreviewGeneration(ac, generation)433 d.clearPreviewGeneration(ac, generation)
347 }434 }
348 435
349-func (d *Daemon) tabByTarget(target picker.Target) *tab {436+func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*session, *tab) {
350 d.mu.Lock()437 d.mu.Lock()
351 sess := d.sessions[target.Session]438 sess := d.sessions[target.Session]
352- d.mu.Unlock()
353 if sess == nil {439 if sess == nil {
354- return nil440+ d.mu.Unlock()
441+ return nil, nil
355 }442 }
356 sess.mu.Lock()443 sess.mu.Lock()
444+ d.mu.Unlock()
357 defer sess.mu.Unlock()445 defer sess.mu.Unlock()
446+ if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt) {
447+ return nil, nil
448+ }
449+ if intent == pickerMoveTab {
450+ if sess.active < 0 || sess.active >= len(sess.tabs) {
451+ return nil, nil
452+ }
453+ return sess, sess.tabs[sess.active]
454+ }
455+ if target.TabID != "" {
456+ for _, tb := range sess.tabs {
457+ if domain.TabStableID(tb.stableID) == target.TabID {
458+ return sess, tb
459+ }
460+ }
461+ return nil, nil
462+ }
358 if target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) {463 if target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) {
359- return nil464+ return nil, nil
360 }465 }
361- return sess.tabs[target.TabIndex]466+ return sess, sess.tabs[target.TabIndex]
467+}
468+
469+func pickerTargetsEqual(left, right picker.Target) bool {
470+ if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name ||
471+ left.TabID != right.TabID || left.TabIndex != right.TabIndex || left.Stopped != right.Stopped {
472+ return false
473+ }
474+ if left.ExpectedCreatedAt == nil || right.ExpectedCreatedAt == nil {
475+ return left.ExpectedCreatedAt == nil && right.ExpectedCreatedAt == nil
476+ }
477+ return *left.ExpectedCreatedAt == *right.ExpectedCreatedAt
362 }478 }
363 479
364 func (d *Daemon) sessionByID(id domain.SessionID) *session {480 func (d *Daemon) sessionByID(id domain.SessionID) *session {
@@ -770,13 +886,35 @@ func (d *Daemon) refreshPicker(ac *attachedClient) {
770 if sess == nil {886 if sess == nil {
771 return887 return
772 }888 }
773- views, curTab := d.pickerViews(sess)889+ rt := ac.overlays
774- ac.overlays.pickerMu.Lock()890+ rt.pickerMu.Lock()
775- if ac.overlays.picker != nil {891+ if rt.picker == nil {
776- ac.overlays.picker = picker.New(views, sess.id, curTab)892+ rt.pickerMu.Unlock()
893+ return
894+ }
895+ intent, source := rt.pickerIntent, rt.pickerSource
896+ current := picker.SourceFilter{}
897+ if intent != pickerNavigate {
898+ selected, _ := rt.picker.Selected()
899+ current = picker.SourceFilter{Session: selected.Session, Incarnation: selected.Incarnation, TabID: selected.TabID}
900+ }
901+ rt.pickerMu.Unlock()
902+ model := d.newPickerModel(sess, intent, source, current)
903+ if intent != pickerNavigate {
904+ if _, ok := model.Selected(); !ok {
905+ d.closePicker(ac)
906+ return
907+ }
908+ }
909+ rt.pickerMu.Lock()
910+ updated := rt.picker != nil && rt.pickerIntent == intent && rt.pickerSource == source
911+ if updated {
912+ rt.picker = model
913+ }
914+ rt.pickerMu.Unlock()
915+ if updated {
916+ d.registerPreviewForSelection(ac)
777 }917 }
778- ac.overlays.pickerMu.Unlock()
779- d.registerPreviewForSelection(ac)
780 }918 }
781 919
782 func snapshotPickerPreview(tb *tab) picker.Preview {920 func snapshotPickerPreview(tb *tab) picker.Preview {
M internal/usecase/daemon/picker_test.go +366 -8
@@ -23,6 +23,11 @@ import (
2323 "github.com/bnema/vev/pkg/vt"
2424 )
2525
26+var (
27+ _ domain.TabStableID = moveSourceLocator{}.TabID
28+ _ domain.PaneStableID = moveSourceLocator{}.PaneID
29+)
30+
2631 // --- test doubles -----------------------------------------------------------
2732
2833 type refusingSnapshotDeleteRepository struct {
@@ -74,16 +79,16 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
7479 d.sessions[current.id] = current
7580 d.sessions[ringing.id] = ringing
7681
77- views, curTab := d.pickerViews(current)
82+ views, currentSelection := d.pickerViews(current)
7883
79- require.Equal(t, 0, curTab)
84+ require.Equal(t, picker.SourceFilter{Session: current.id}, currentSelection)
8085 require.Len(t, views, 2)
8186 require.Equal(t, "alpha", views[0].Name)
8287 require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs)
8388 require.Equal(t, "beta ", views[1].Name)
8489 require.Equal(t, []picker.TabEntry{{Name: "shell"}, {Name: "logs", Attention: true}}, views[1].Tabs)
8590
86- model := picker.New(views, current.id, 0)
91+ model := picker.New(views, picker.SelectionConfig{Mode: picker.SelectNavigationTab, Current: picker.SourceFilter{Session: current.id, TabID: views[0].Tabs[0].TabID}})
8792 model.Down()
8893 model.Down()
8994 target, ok := model.Selected()
@@ -130,6 +135,294 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {
130135 require.Equal(t, int64(24), *views[1].ExpectedCreatedAt)
131136 }
132137
138+func TestPickerCanonicalViewsLeaveMoveFilteringToModel(t *testing.T) {
139+ d := newTestDaemon(t, nil, stubClock{})
140+ source := &session{
141+ id: "source", name: "source", incarnation: domain.IncarnationID{1}, active: 0,
142+ tabs: []*tab{
143+ {stableID: "source-tab", panes: map[layout.PaneID]*pane{}},
144+ {stableID: "sibling-tab", panes: map[layout.PaneID]*pane{}},
145+ },
146+ }
147+ destination := &session{
148+ id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true,
149+ tabs: []*tab{{stableID: "destination-tab", panes: map[layout.PaneID]*pane{}}},
150+ }
151+ d.sessions[source.id] = source
152+ d.sessions[destination.id] = destination
153+ d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 9, incarnation: domain.IncarnationID{9}}
154+ sourceLocator := moveSourceLocator{
155+ Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
156+ TabID: "source-tab",
157+ PaneID: "source-pane",
158+ }
159+
160+ views, current := d.pickerViews(source)
161+ require.Len(t, views, 3, "canonical capture includes active and stopped lifecycles")
162+ require.Equal(t, picker.SourceFilter{Session: source.id, Incarnation: source.incarnation, TabID: "source-tab"}, current)
163+ var sourceView picker.SessionView
164+ for _, view := range views {
165+ if view.ID == source.id {
166+ sourceView = view
167+ }
168+ }
169+ require.Equal(t, []picker.TabEntry{{TabID: "source-tab", Name: "1"}, {TabID: "sibling-tab", Name: "2"}}, sourceView.Tabs,
170+ "daemon capture does not remove the move source")
171+
172+ paneModel := d.newPickerModel(source, pickerMovePane, sourceLocator, picker.SourceFilter{
173+ Session: source.id, Incarnation: source.incarnation, TabID: "sibling-tab",
174+ })
175+ paneTarget, ok := paneModel.Selected()
176+ require.True(t, ok)
177+ require.Equal(t, domain.TabStableID("sibling-tab"), paneTarget.TabID)
178+
179+ tabModel := d.newPickerModel(source, pickerMoveTab, sourceLocator, picker.SourceFilter{})
180+ tabTarget, ok := tabModel.Selected()
181+ require.True(t, ok)
182+ require.Equal(t, destination.id, tabTarget.Session)
183+ require.Equal(t, destination.incarnation, tabTarget.Incarnation)
184+ require.Equal(t, destination.name, tabTarget.Name)
185+ require.Equal(t, -1, tabTarget.TabIndex)
186+}
187+
188+func TestPickerMoveWithoutDestinationDoesNotPublishOverlay(t *testing.T) {
189+ d, sess, ac, _, releases := newManualTabSession(t, 1)
190+ defer releases[0]()
191+ sess.mu.Lock()
192+ sess.incarnation = domain.IncarnationID{1}
193+ tb := sess.tabs[0]
194+ sess.mu.Unlock()
195+ tb.mu.Lock()
196+ tabID := tb.stableID
197+ paneID := tb.focusedPane().stableID
198+ tb.mu.Unlock()
199+ source := moveSourceLocator{
200+ Session: moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation, Name: sess.name},
201+ TabID: domain.TabStableID(tabID), PaneID: domain.PaneStableID(paneID),
202+ }
203+
204+ for _, intent := range []pickerIntent{pickerMovePane, pickerMoveTab} {
205+ err := d.enterPickerForIntent(sess, ac, intent, source)
206+
207+ require.ErrorIs(t, err, errNoMoveDestination)
208+ require.False(t, ac.overlays.pickerActive())
209+ ac.overlays.pickerMu.Lock()
210+ require.Nil(t, ac.overlays.pickerPreview)
211+ require.Nil(t, ac.overlays.pickerPreviewSession)
212+ require.Zero(t, ac.overlays.pickerPreviewGeneration, "failed entry must not publish or retire a picker generation")
213+ ac.overlays.pickerMu.Unlock()
214+ }
215+}
216+
217+func TestPickerMovePaneStoresSourceAndCleansPreviewSubscription(t *testing.T) {
218+ p1, release1 := newBlockingPTY(t)
219+ p2, release2 := newBlockingPTY(t)
220+ defer release1()
221+ defer release2()
222+ d, sourceSession, ac, _ := newManualSessionWithPTYs(t, p1)
223+ sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "z-source", domain.IncarnationID{1}
224+ delete(d.sessions, domain.SessionID("manual"))
225+ d.sessions[sourceSession.id] = sourceSession
226+ targetTab := newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel)
227+ targetTab.stableID = "destination-tab"
228+ targetSession := &session{
229+ id: "destination", name: "a-destination", incarnation: domain.IncarnationID{2}, ephemeral: true,
230+ ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{targetTab},
231+ }
232+ d.sessions[targetSession.id] = targetSession
233+ sourceTab := sourceSession.tabs[0]
234+ sourceTab.mu.Lock()
235+ sourceTab.stableID = "source-tab"
236+ sourcePaneID := sourceTab.focusedPane().stableID
237+ sourceTab.mu.Unlock()
238+ source := moveSourceLocator{
239+ Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name},
240+ TabID: "source-tab", PaneID: domain.PaneStableID(sourcePaneID),
241+ }
242+
243+ require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMovePane, source))
244+ ac.overlays.pickerMu.Lock()
245+ storedIntent, storedSource := ac.overlays.pickerIntent, ac.overlays.pickerSource
246+ selected, ok := ac.overlays.picker.Selected()
247+ generation := ac.overlays.pickerPreviewGeneration
248+ require.Same(t, targetTab, ac.overlays.pickerPreview)
249+ require.Same(t, targetSession, ac.overlays.pickerPreviewSession)
250+ ac.overlays.pickerMu.Unlock()
251+ require.Equal(t, pickerMovePane, storedIntent)
252+ require.Equal(t, source, storedSource)
253+ require.True(t, ok)
254+ require.Equal(t, targetSession.id, selected.Session)
255+ require.Equal(t, targetSession.incarnation, selected.Incarnation)
256+ require.Equal(t, targetSession.name, selected.Name)
257+ require.Equal(t, domain.TabStableID(targetTab.stableID), selected.TabID)
258+ require.Equal(t, 0, selected.TabIndex)
259+ require.Nil(t, selected.ExpectedCreatedAt)
260+ rc := targetSession.renderCoordinator()
261+ require.NotNil(t, rc)
262+ rc.mu.Lock()
263+ subscription, subscribed := rc.previewWakes[ac]
264+ rc.mu.Unlock()
265+ require.True(t, subscribed)
266+ require.Equal(t, generation, subscription.generation)
267+
268+ d.enterPicker(sourceSession, ac)
269+
270+ rc.mu.Lock()
271+ _, subscribed = rc.previewWakes[ac]
272+ rc.mu.Unlock()
273+ require.False(t, subscribed, "picker replacement must retire the prior cross-session preview subscription")
274+ ac.overlays.pickerMu.Lock()
275+ require.NotNil(t, ac.overlays.picker)
276+ require.Equal(t, pickerNavigate, ac.overlays.pickerIntent)
277+ require.Equal(t, moveSourceLocator{}, ac.overlays.pickerSource)
278+ ac.overlays.pickerMu.Unlock()
279+
280+ d.closePicker(ac)
281+ ac.overlays.pickerMu.Lock()
282+ require.Nil(t, ac.overlays.picker)
283+ require.Nil(t, ac.overlays.pickerPreview)
284+ ac.overlays.pickerMu.Unlock()
285+}
286+
287+func TestPickerMoveTabPreviewsDestinationActiveTabWithoutActivatingIt(t *testing.T) {
288+ p1, release1 := newBlockingPTY(t)
289+ p2, release2 := newBlockingPTY(t)
290+ p3, release3 := newBlockingPTY(t)
291+ defer release1()
292+ defer release2()
293+ defer release3()
294+ d, sourceSession, ac, _ := newManualSessionWithPTYs(t, p1)
295+ sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1}
296+ delete(d.sessions, domain.SessionID("manual"))
297+ d.sessions[sourceSession.id] = sourceSession
298+ target := &session{
299+ id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true, active: 1,
300+ ctx: sourceSession.ctx, cancel: func() {},
301+ tabs: []*tab{
302+ newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel),
303+ newTestTabWithContext(p3, sourceSession.ctx, sourceSession.cancel),
304+ },
305+ }
306+ target.tabs[0].stableID, target.tabs[1].stableID = "first", "active"
307+ d.sessions[target.id] = target
308+ source := moveSourceLocator{
309+ Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name},
310+ TabID: domain.TabStableID(sourceSession.tabs[0].stableID),
311+ }
312+
313+ require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMoveTab, source))
314+
315+ ac.overlays.pickerMu.Lock()
316+ selected, ok := ac.overlays.picker.Selected()
317+ require.Same(t, target.tabs[1], ac.overlays.pickerPreview)
318+ ac.overlays.pickerMu.Unlock()
319+ require.True(t, ok)
320+ require.Equal(t, target.id, selected.Session)
321+ require.Equal(t, target.incarnation, selected.Incarnation)
322+ require.Equal(t, target.name, selected.Name)
323+ require.Equal(t, -1, selected.TabIndex)
324+ require.Nil(t, selected.ExpectedCreatedAt)
325+ require.Equal(t, 1, activeTabIndex(target), "preview must not mutate destination focus")
326+ d.closePicker(ac)
327+}
328+
329+func TestPickerMoveRefreshPreservesSelectedDestination(t *testing.T) {
330+ t.Run("pane destination stable tab", func(t *testing.T) {
331+ d, sourceSession, ac, _, sourceReleases := newManualTabSession(t, 1)
332+ defer releaseAll(sourceReleases)
333+ sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1}
334+ sourceSession.tabs[0].stableID = "source-tab"
335+ delete(d.sessions, domain.SessionID("manual"))
336+ d.sessions[sourceSession.id] = sourceSession
337+
338+ firstPTY, releaseFirst := newBlockingPTY(t)
339+ defer releaseFirst()
340+ secondPTY, releaseSecond := newBlockingPTY(t)
341+ defer releaseSecond()
342+ firstTab := newTestTabWithContext(firstPTY, sourceSession.ctx, sourceSession.cancel)
343+ secondTab := newTestTabWithContext(secondPTY, sourceSession.ctx, sourceSession.cancel)
344+ firstTab.stableID, secondTab.stableID = "first", "selected"
345+ destination := &session{
346+ id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true,
347+ ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{firstTab, secondTab},
348+ }
349+ d.sessions[destination.id] = destination
350+ source := moveSourceLocator{
351+ Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name},
352+ TabID: domain.TabStableID(sourceSession.tabs[0].stableID),
353+ }
354+
355+ require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMovePane, source))
356+ ac.overlays.pickerMu.Lock()
357+ ac.overlays.picker.Down()
358+ before, ok := ac.overlays.picker.Selected()
359+ ac.overlays.pickerMu.Unlock()
360+ require.True(t, ok)
361+ require.Equal(t, domain.TabStableID("selected"), before.TabID)
362+
363+ destination.mu.Lock()
364+ destination.tabs[0], destination.tabs[1] = destination.tabs[1], destination.tabs[0]
365+ destination.mu.Unlock()
366+ d.refreshPicker(ac)
367+
368+ ac.overlays.pickerMu.Lock()
369+ after, ok := ac.overlays.picker.Selected()
370+ ac.overlays.pickerMu.Unlock()
371+ require.True(t, ok)
372+ require.Equal(t, before.Session, after.Session)
373+ require.Equal(t, before.Incarnation, after.Incarnation)
374+ require.Equal(t, before.TabID, after.TabID)
375+ require.Equal(t, 0, after.TabIndex, "mutable index follows the preserved stable tab")
376+ d.closePicker(ac)
377+ })
378+
379+ t.Run("tab destination session", func(t *testing.T) {
380+ d, sourceSession, ac, _, sourceReleases := newManualTabSession(t, 1)
381+ defer releaseAll(sourceReleases)
382+ sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1}
383+ sourceSession.tabs[0].stableID = "source-tab"
384+ delete(d.sessions, domain.SessionID("manual"))
385+ d.sessions[sourceSession.id] = sourceSession
386+
387+ releases := make([]func(), 0, 2)
388+ for i, destination := range []struct {
389+ id domain.SessionID
390+ name string
391+ }{{id: "first", name: "0-first"}, {id: "selected", name: "1-selected"}} {
392+ pty, release := newBlockingPTY(t)
393+ releases = append(releases, release)
394+ tb := newTestTabWithContext(pty, sourceSession.ctx, sourceSession.cancel)
395+ tb.stableID = string(destination.id) + "-tab"
396+ d.sessions[destination.id] = &session{
397+ id: destination.id, name: destination.name, incarnation: domain.IncarnationID{byte(i + 2)}, ephemeral: true,
398+ ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{tb},
399+ }
400+ }
401+ defer releaseAll(releases)
402+ source := moveSourceLocator{
403+ Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name},
404+ TabID: domain.TabStableID(sourceSession.tabs[0].stableID),
405+ }
406+
407+ require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMoveTab, source))
408+ ac.overlays.pickerMu.Lock()
409+ ac.overlays.picker.Down()
410+ before, ok := ac.overlays.picker.Selected()
411+ ac.overlays.pickerMu.Unlock()
412+ require.True(t, ok)
413+ require.Equal(t, domain.SessionID("selected"), before.Session)
414+
415+ d.refreshPicker(ac)
416+
417+ ac.overlays.pickerMu.Lock()
418+ after, ok := ac.overlays.picker.Selected()
419+ ac.overlays.pickerMu.Unlock()
420+ require.True(t, ok)
421+ require.Equal(t, before, after)
422+ d.closePicker(ac)
423+ })
424+}
425+
133426 func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) {
134427 p, release := newBlockingPTY(t)
135428 defer release()
@@ -147,7 +440,7 @@ func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) {
147440 break
148441 }
149442 }
150- model := picker.New([]picker.SessionView{originalView}, original.id, 0)
443+ model := picker.New([]picker.SessionView{originalView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab, Current: picker.SourceFilter{Session: original.id, TabID: originalView.Tabs[0].TabID}})
151444 target, ok := model.Selected()
152445 require.True(t, ok)
153446
@@ -193,7 +486,10 @@ func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {
193486 views, _ := d.pickerViews(sess)
194487
195488 require.Len(t, views, 1)
196- require.Equal(t, []picker.TabEntry{{Name: "1", Detail: " (vim)"}, {Name: "logs", Detail: " (sh)", Attention: true}}, views[0].Tabs)
489+ require.Equal(t, []picker.TabEntry{
490+ {TabID: domain.TabStableID(sess.tabs[0].stableID), Name: "1", Detail: " (vim)"},
491+ {TabID: domain.TabStableID(sess.tabs[1].stableID), Name: "logs", Detail: " (sh)", Attention: true},
492+ }, views[0].Tabs)
197493 }
198494
199495 func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) {
@@ -217,7 +513,10 @@ func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) {
217513 views, _ := d.pickerViews(sess)
218514
219515 require.Len(t, views, 1)
220- require.Equal(t, []picker.TabEntry{{Name: "1", Detail: " (vim)"}, {Name: "logs", Detail: " (sh)"}}, views[0].Tabs)
516+ require.Equal(t, []picker.TabEntry{
517+ {TabID: domain.TabStableID(sess.tabs[0].stableID), Name: "1", Detail: " (vim)"},
518+ {TabID: domain.TabStableID(sess.tabs[1].stableID), Name: "logs", Detail: " (sh)"},
519+ }, views[0].Tabs)
221520 }
222521
223522 func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) {
@@ -879,7 +1178,7 @@ func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *t
8791178 pickerModel: picker.New([]picker.SessionView{
8801179 {ID: "selected", Name: "selected", Tabs: []picker.TabEntry{{Name: "one"}}, Active: 0},
8811180 {ID: "inactive", Name: "inactive", Tabs: []picker.TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0},
882- }, "selected", 0),
1181+ }, picker.SelectionConfig{Mode: picker.SelectNavigationTab}),
8831182 }
8841183
8851184 captureOverlayLayers(&state, snap, domain.PaletteConfig{})
@@ -908,7 +1207,7 @@ func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *t
9081207 }
9091208
9101209 func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testing.T) {
911- model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0)
1210+ model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
9121211 state := capturedRenderState{theme: themeui.BuiltinDark, styles: themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles}
9131212 cases := []struct {
9141213 name string
@@ -1160,6 +1459,48 @@ func TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut(t *t
11601459 require.Same(t, from, ac.currentSession(), "a failed restore must leave the client on its origin session")
11611460 }
11621461
1462+func TestPickerNavigationRefreshAfterSuccessfulDeleteSelectsAttachedActiveStableTab(t *testing.T) {
1463+ d, current, ac, _, currentReleases := newManualTabSession(t, 2)
1464+ defer releaseAll(currentReleases)
1465+ current.id, current.name, current.active = "current", "z-current", 1
1466+ current.tabs[0].stableID, current.tabs[1].stableID = "current-first", "current-active"
1467+ delete(d.sessions, domain.SessionID("manual"))
1468+ d.sessions[current.id] = current
1469+
1470+ otherPTY, releaseOther := newBlockingPTY(t)
1471+ defer releaseOther()
1472+ otherTab := newTestTabWithContext(otherPTY, current.ctx, current.cancel)
1473+ otherTab.stableID = "other-tab"
1474+ other := &session{id: "other", name: "a-other", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}}
1475+ d.sessions[other.id] = other
1476+
1477+ targetPTY, releaseTarget := newBlockingPTY(t)
1478+ defer releaseTarget()
1479+ targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel)
1480+ targetTab.stableID = "target-tab"
1481+ target := &session{id: "target", name: "m-target", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}
1482+ d.sessions[target.id] = target
1483+
1484+ d.enterPicker(current, ac)
1485+ d.handlePickerInput(ac, []byte("k"))
1486+ d.handlePickerInput(ac, []byte("k"))
1487+ ac.overlays.pickerMu.Lock()
1488+ selected, ok := ac.overlays.picker.Selected()
1489+ ac.overlays.pickerMu.Unlock()
1490+ require.True(t, ok)
1491+ require.Equal(t, target.id, selected.Session, "test must delete the selected non-attached session")
1492+
1493+ d.handlePickerInput(ac, []byte("x"))
1494+
1495+ ac.overlays.pickerMu.Lock()
1496+ selected, ok = ac.overlays.picker.Selected()
1497+ ac.overlays.pickerMu.Unlock()
1498+ require.True(t, ok)
1499+ require.Equal(t, current.id, selected.Session)
1500+ require.Equal(t, domain.TabStableID("current-active"), selected.TabID)
1501+ require.Equal(t, 1, selected.TabIndex)
1502+}
1503+
11631504 func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t *testing.T) {
11641505 d, from, ac, sends, releases := newRecentNavigationTestSessions(t)
11651506 defer releaseAll(releases)
@@ -1206,6 +1547,12 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
12061547 p, release := newBlockingPTY(t)
12071548 defer release()
12081549 d, from, ac, sends := newManualSessionWithPTYs(t, p)
1550+ otherPTY, releaseOther := newBlockingPTY(t)
1551+ defer releaseOther()
1552+ otherTab := newTestTabWithContext(otherPTY, from.ctx, from.cancel)
1553+ otherTab.stableID = "other-tab"
1554+ other := &session{id: "other", name: "a-other", ephemeral: true, ctx: from.ctx, cancel: func() {}, tabs: []*tab{otherTab}}
1555+ d.sessions[other.id] = other
12091556 cause := errors.New("delete failed")
12101557 store, state := newMockStore(t)
12111558 WithStore(t, store)(d)
@@ -1236,4 +1583,15 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
12361583 d.mu.Unlock()
12371584 require.True(t, retained, "failed deletion must retain the reserved name")
12381585 require.True(t, stopped.purging, "an uncertain deletion stays fenced from restore")
1586+
1587+ from.mu.Lock()
1588+ activeTab := from.tabs[from.active]
1589+ activeTabID := activeTab.stableID
1590+ from.mu.Unlock()
1591+ ac.overlays.pickerMu.Lock()
1592+ selected, selectedOK := ac.overlays.picker.Selected()
1593+ ac.overlays.pickerMu.Unlock()
1594+ require.True(t, selectedOK)
1595+ require.Equal(t, from.id, selected.Session, "failed x refresh resets navigation to the attached session")
1596+ require.Equal(t, domain.TabStableID(activeTabID), selected.TabID, "failed x refresh follows the attached session's stable active tab")
12391597 }
@@ -23,6 +23,11 @@ import (
23 "github.com/bnema/vev/pkg/vt"23 "github.com/bnema/vev/pkg/vt"
24 )24 )
25 25
26+var (
27+ _ domain.TabStableID = moveSourceLocator{}.TabID
28+ _ domain.PaneStableID = moveSourceLocator{}.PaneID
29+)
30+
26 // --- test doubles -----------------------------------------------------------31 // --- test doubles -----------------------------------------------------------
27 32
28 type refusingSnapshotDeleteRepository struct {33 type refusingSnapshotDeleteRepository struct {
@@ -74,16 +79,16 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
74 d.sessions[current.id] = current79 d.sessions[current.id] = current
75 d.sessions[ringing.id] = ringing80 d.sessions[ringing.id] = ringing
76 81
77- views, curTab := d.pickerViews(current)82+ views, currentSelection := d.pickerViews(current)
78 83
79- require.Equal(t, 0, curTab)84+ require.Equal(t, picker.SourceFilter{Session: current.id}, currentSelection)
80 require.Len(t, views, 2)85 require.Len(t, views, 2)
81 require.Equal(t, "alpha", views[0].Name)86 require.Equal(t, "alpha", views[0].Name)
82 require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs)87 require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs)
83 require.Equal(t, "beta ", views[1].Name)88 require.Equal(t, "beta ", views[1].Name)
84 require.Equal(t, []picker.TabEntry{{Name: "shell"}, {Name: "logs", Attention: true}}, views[1].Tabs)89 require.Equal(t, []picker.TabEntry{{Name: "shell"}, {Name: "logs", Attention: true}}, views[1].Tabs)
85 90
86- model := picker.New(views, current.id, 0)91+ model := picker.New(views, picker.SelectionConfig{Mode: picker.SelectNavigationTab, Current: picker.SourceFilter{Session: current.id, TabID: views[0].Tabs[0].TabID}})
87 model.Down()92 model.Down()
88 model.Down()93 model.Down()
89 target, ok := model.Selected()94 target, ok := model.Selected()
@@ -130,6 +135,294 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {
130 require.Equal(t, int64(24), *views[1].ExpectedCreatedAt)135 require.Equal(t, int64(24), *views[1].ExpectedCreatedAt)
131 }136 }
132 137
138+func TestPickerCanonicalViewsLeaveMoveFilteringToModel(t *testing.T) {
139+ d := newTestDaemon(t, nil, stubClock{})
140+ source := &session{
141+ id: "source", name: "source", incarnation: domain.IncarnationID{1}, active: 0,
142+ tabs: []*tab{
143+ {stableID: "source-tab", panes: map[layout.PaneID]*pane{}},
144+ {stableID: "sibling-tab", panes: map[layout.PaneID]*pane{}},
145+ },
146+ }
147+ destination := &session{
148+ id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true,
149+ tabs: []*tab{{stableID: "destination-tab", panes: map[layout.PaneID]*pane{}}},
150+ }
151+ d.sessions[source.id] = source
152+ d.sessions[destination.id] = destination
153+ d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 9, incarnation: domain.IncarnationID{9}}
154+ sourceLocator := moveSourceLocator{
155+ Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
156+ TabID: "source-tab",
157+ PaneID: "source-pane",
158+ }
159+
160+ views, current := d.pickerViews(source)
161+ require.Len(t, views, 3, "canonical capture includes active and stopped lifecycles")
162+ require.Equal(t, picker.SourceFilter{Session: source.id, Incarnation: source.incarnation, TabID: "source-tab"}, current)
163+ var sourceView picker.SessionView
164+ for _, view := range views {
165+ if view.ID == source.id {
166+ sourceView = view
167+ }
168+ }
169+ require.Equal(t, []picker.TabEntry{{TabID: "source-tab", Name: "1"}, {TabID: "sibling-tab", Name: "2"}}, sourceView.Tabs,
170+ "daemon capture does not remove the move source")
171+
172+ paneModel := d.newPickerModel(source, pickerMovePane, sourceLocator, picker.SourceFilter{
173+ Session: source.id, Incarnation: source.incarnation, TabID: "sibling-tab",
174+ })
175+ paneTarget, ok := paneModel.Selected()
176+ require.True(t, ok)
177+ require.Equal(t, domain.TabStableID("sibling-tab"), paneTarget.TabID)
178+
179+ tabModel := d.newPickerModel(source, pickerMoveTab, sourceLocator, picker.SourceFilter{})
180+ tabTarget, ok := tabModel.Selected()
181+ require.True(t, ok)
182+ require.Equal(t, destination.id, tabTarget.Session)
183+ require.Equal(t, destination.incarnation, tabTarget.Incarnation)
184+ require.Equal(t, destination.name, tabTarget.Name)
185+ require.Equal(t, -1, tabTarget.TabIndex)
186+}
187+
188+func TestPickerMoveWithoutDestinationDoesNotPublishOverlay(t *testing.T) {
189+ d, sess, ac, _, releases := newManualTabSession(t, 1)
190+ defer releases[0]()
191+ sess.mu.Lock()
192+ sess.incarnation = domain.IncarnationID{1}
193+ tb := sess.tabs[0]
194+ sess.mu.Unlock()
195+ tb.mu.Lock()
196+ tabID := tb.stableID
197+ paneID := tb.focusedPane().stableID
198+ tb.mu.Unlock()
199+ source := moveSourceLocator{
200+ Session: moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation, Name: sess.name},
201+ TabID: domain.TabStableID(tabID), PaneID: domain.PaneStableID(paneID),
202+ }
203+
204+ for _, intent := range []pickerIntent{pickerMovePane, pickerMoveTab} {
205+ err := d.enterPickerForIntent(sess, ac, intent, source)
206+
207+ require.ErrorIs(t, err, errNoMoveDestination)
208+ require.False(t, ac.overlays.pickerActive())
209+ ac.overlays.pickerMu.Lock()
210+ require.Nil(t, ac.overlays.pickerPreview)
211+ require.Nil(t, ac.overlays.pickerPreviewSession)
212+ require.Zero(t, ac.overlays.pickerPreviewGeneration, "failed entry must not publish or retire a picker generation")
213+ ac.overlays.pickerMu.Unlock()
214+ }
215+}
216+
217+func TestPickerMovePaneStoresSourceAndCleansPreviewSubscription(t *testing.T) {
218+ p1, release1 := newBlockingPTY(t)
219+ p2, release2 := newBlockingPTY(t)
220+ defer release1()
221+ defer release2()
222+ d, sourceSession, ac, _ := newManualSessionWithPTYs(t, p1)
223+ sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "z-source", domain.IncarnationID{1}
224+ delete(d.sessions, domain.SessionID("manual"))
225+ d.sessions[sourceSession.id] = sourceSession
226+ targetTab := newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel)
227+ targetTab.stableID = "destination-tab"
228+ targetSession := &session{
229+ id: "destination", name: "a-destination", incarnation: domain.IncarnationID{2}, ephemeral: true,
230+ ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{targetTab},
231+ }
232+ d.sessions[targetSession.id] = targetSession
233+ sourceTab := sourceSession.tabs[0]
234+ sourceTab.mu.Lock()
235+ sourceTab.stableID = "source-tab"
236+ sourcePaneID := sourceTab.focusedPane().stableID
237+ sourceTab.mu.Unlock()
238+ source := moveSourceLocator{
239+ Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name},
240+ TabID: "source-tab", PaneID: domain.PaneStableID(sourcePaneID),
241+ }
242+
243+ require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMovePane, source))
244+ ac.overlays.pickerMu.Lock()
245+ storedIntent, storedSource := ac.overlays.pickerIntent, ac.overlays.pickerSource
246+ selected, ok := ac.overlays.picker.Selected()
247+ generation := ac.overlays.pickerPreviewGeneration
248+ require.Same(t, targetTab, ac.overlays.pickerPreview)
249+ require.Same(t, targetSession, ac.overlays.pickerPreviewSession)
250+ ac.overlays.pickerMu.Unlock()
251+ require.Equal(t, pickerMovePane, storedIntent)
252+ require.Equal(t, source, storedSource)
253+ require.True(t, ok)
254+ require.Equal(t, targetSession.id, selected.Session)
255+ require.Equal(t, targetSession.incarnation, selected.Incarnation)
256+ require.Equal(t, targetSession.name, selected.Name)
257+ require.Equal(t, domain.TabStableID(targetTab.stableID), selected.TabID)
258+ require.Equal(t, 0, selected.TabIndex)
259+ require.Nil(t, selected.ExpectedCreatedAt)
260+ rc := targetSession.renderCoordinator()
261+ require.NotNil(t, rc)
262+ rc.mu.Lock()
263+ subscription, subscribed := rc.previewWakes[ac]
264+ rc.mu.Unlock()
265+ require.True(t, subscribed)
266+ require.Equal(t, generation, subscription.generation)
267+
268+ d.enterPicker(sourceSession, ac)
269+
270+ rc.mu.Lock()
271+ _, subscribed = rc.previewWakes[ac]
272+ rc.mu.Unlock()
273+ require.False(t, subscribed, "picker replacement must retire the prior cross-session preview subscription")
274+ ac.overlays.pickerMu.Lock()
275+ require.NotNil(t, ac.overlays.picker)
276+ require.Equal(t, pickerNavigate, ac.overlays.pickerIntent)
277+ require.Equal(t, moveSourceLocator{}, ac.overlays.pickerSource)
278+ ac.overlays.pickerMu.Unlock()
279+
280+ d.closePicker(ac)
281+ ac.overlays.pickerMu.Lock()
282+ require.Nil(t, ac.overlays.picker)
283+ require.Nil(t, ac.overlays.pickerPreview)
284+ ac.overlays.pickerMu.Unlock()
285+}
286+
287+func TestPickerMoveTabPreviewsDestinationActiveTabWithoutActivatingIt(t *testing.T) {
288+ p1, release1 := newBlockingPTY(t)
289+ p2, release2 := newBlockingPTY(t)
290+ p3, release3 := newBlockingPTY(t)
291+ defer release1()
292+ defer release2()
293+ defer release3()
294+ d, sourceSession, ac, _ := newManualSessionWithPTYs(t, p1)
295+ sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1}
296+ delete(d.sessions, domain.SessionID("manual"))
297+ d.sessions[sourceSession.id] = sourceSession
298+ target := &session{
299+ id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true, active: 1,
300+ ctx: sourceSession.ctx, cancel: func() {},
301+ tabs: []*tab{
302+ newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel),
303+ newTestTabWithContext(p3, sourceSession.ctx, sourceSession.cancel),
304+ },
305+ }
306+ target.tabs[0].stableID, target.tabs[1].stableID = "first", "active"
307+ d.sessions[target.id] = target
308+ source := moveSourceLocator{
309+ Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name},
310+ TabID: domain.TabStableID(sourceSession.tabs[0].stableID),
311+ }
312+
313+ require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMoveTab, source))
314+
315+ ac.overlays.pickerMu.Lock()
316+ selected, ok := ac.overlays.picker.Selected()
317+ require.Same(t, target.tabs[1], ac.overlays.pickerPreview)
318+ ac.overlays.pickerMu.Unlock()
319+ require.True(t, ok)
320+ require.Equal(t, target.id, selected.Session)
321+ require.Equal(t, target.incarnation, selected.Incarnation)
322+ require.Equal(t, target.name, selected.Name)
323+ require.Equal(t, -1, selected.TabIndex)
324+ require.Nil(t, selected.ExpectedCreatedAt)
325+ require.Equal(t, 1, activeTabIndex(target), "preview must not mutate destination focus")
326+ d.closePicker(ac)
327+}
328+
329+func TestPickerMoveRefreshPreservesSelectedDestination(t *testing.T) {
330+ t.Run("pane destination stable tab", func(t *testing.T) {
331+ d, sourceSession, ac, _, sourceReleases := newManualTabSession(t, 1)
332+ defer releaseAll(sourceReleases)
333+ sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1}
334+ sourceSession.tabs[0].stableID = "source-tab"
335+ delete(d.sessions, domain.SessionID("manual"))
336+ d.sessions[sourceSession.id] = sourceSession
337+
338+ firstPTY, releaseFirst := newBlockingPTY(t)
339+ defer releaseFirst()
340+ secondPTY, releaseSecond := newBlockingPTY(t)
341+ defer releaseSecond()
342+ firstTab := newTestTabWithContext(firstPTY, sourceSession.ctx, sourceSession.cancel)
343+ secondTab := newTestTabWithContext(secondPTY, sourceSession.ctx, sourceSession.cancel)
344+ firstTab.stableID, secondTab.stableID = "first", "selected"
345+ destination := &session{
346+ id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true,
347+ ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{firstTab, secondTab},
348+ }
349+ d.sessions[destination.id] = destination
350+ source := moveSourceLocator{
351+ Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name},
352+ TabID: domain.TabStableID(sourceSession.tabs[0].stableID),
353+ }
354+
355+ require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMovePane, source))
356+ ac.overlays.pickerMu.Lock()
357+ ac.overlays.picker.Down()
358+ before, ok := ac.overlays.picker.Selected()
359+ ac.overlays.pickerMu.Unlock()
360+ require.True(t, ok)
361+ require.Equal(t, domain.TabStableID("selected"), before.TabID)
362+
363+ destination.mu.Lock()
364+ destination.tabs[0], destination.tabs[1] = destination.tabs[1], destination.tabs[0]
365+ destination.mu.Unlock()
366+ d.refreshPicker(ac)
367+
368+ ac.overlays.pickerMu.Lock()
369+ after, ok := ac.overlays.picker.Selected()
370+ ac.overlays.pickerMu.Unlock()
371+ require.True(t, ok)
372+ require.Equal(t, before.Session, after.Session)
373+ require.Equal(t, before.Incarnation, after.Incarnation)
374+ require.Equal(t, before.TabID, after.TabID)
375+ require.Equal(t, 0, after.TabIndex, "mutable index follows the preserved stable tab")
376+ d.closePicker(ac)
377+ })
378+
379+ t.Run("tab destination session", func(t *testing.T) {
380+ d, sourceSession, ac, _, sourceReleases := newManualTabSession(t, 1)
381+ defer releaseAll(sourceReleases)
382+ sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1}
383+ sourceSession.tabs[0].stableID = "source-tab"
384+ delete(d.sessions, domain.SessionID("manual"))
385+ d.sessions[sourceSession.id] = sourceSession
386+
387+ releases := make([]func(), 0, 2)
388+ for i, destination := range []struct {
389+ id domain.SessionID
390+ name string
391+ }{{id: "first", name: "0-first"}, {id: "selected", name: "1-selected"}} {
392+ pty, release := newBlockingPTY(t)
393+ releases = append(releases, release)
394+ tb := newTestTabWithContext(pty, sourceSession.ctx, sourceSession.cancel)
395+ tb.stableID = string(destination.id) + "-tab"
396+ d.sessions[destination.id] = &session{
397+ id: destination.id, name: destination.name, incarnation: domain.IncarnationID{byte(i + 2)}, ephemeral: true,
398+ ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{tb},
399+ }
400+ }
401+ defer releaseAll(releases)
402+ source := moveSourceLocator{
403+ Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name},
404+ TabID: domain.TabStableID(sourceSession.tabs[0].stableID),
405+ }
406+
407+ require.NoError(t, d.enterPickerForIntent(sourceSession, ac, pickerMoveTab, source))
408+ ac.overlays.pickerMu.Lock()
409+ ac.overlays.picker.Down()
410+ before, ok := ac.overlays.picker.Selected()
411+ ac.overlays.pickerMu.Unlock()
412+ require.True(t, ok)
413+ require.Equal(t, domain.SessionID("selected"), before.Session)
414+
415+ d.refreshPicker(ac)
416+
417+ ac.overlays.pickerMu.Lock()
418+ after, ok := ac.overlays.picker.Selected()
419+ ac.overlays.pickerMu.Unlock()
420+ require.True(t, ok)
421+ require.Equal(t, before, after)
422+ d.closePicker(ac)
423+ })
424+}
425+
133 func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) {426 func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) {
134 p, release := newBlockingPTY(t)427 p, release := newBlockingPTY(t)
135 defer release()428 defer release()
@@ -147,7 +440,7 @@ func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) {
147 break440 break
148 }441 }
149 }442 }
150- model := picker.New([]picker.SessionView{originalView}, original.id, 0)443+ model := picker.New([]picker.SessionView{originalView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab, Current: picker.SourceFilter{Session: original.id, TabID: originalView.Tabs[0].TabID}})
151 target, ok := model.Selected()444 target, ok := model.Selected()
152 require.True(t, ok)445 require.True(t, ok)
153 446
@@ -193,7 +486,10 @@ func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {
193 views, _ := d.pickerViews(sess)486 views, _ := d.pickerViews(sess)
194 487
195 require.Len(t, views, 1)488 require.Len(t, views, 1)
196- require.Equal(t, []picker.TabEntry{{Name: "1", Detail: " (vim)"}, {Name: "logs", Detail: " (sh)", Attention: true}}, views[0].Tabs)489+ require.Equal(t, []picker.TabEntry{
490+ {TabID: domain.TabStableID(sess.tabs[0].stableID), Name: "1", Detail: " (vim)"},
491+ {TabID: domain.TabStableID(sess.tabs[1].stableID), Name: "logs", Detail: " (sh)", Attention: true},
492+ }, views[0].Tabs)
197 }493 }
198 494
199 func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) {495 func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) {
@@ -217,7 +513,10 @@ func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) {
217 views, _ := d.pickerViews(sess)513 views, _ := d.pickerViews(sess)
218 514
219 require.Len(t, views, 1)515 require.Len(t, views, 1)
220- require.Equal(t, []picker.TabEntry{{Name: "1", Detail: " (vim)"}, {Name: "logs", Detail: " (sh)"}}, views[0].Tabs)516+ require.Equal(t, []picker.TabEntry{
517+ {TabID: domain.TabStableID(sess.tabs[0].stableID), Name: "1", Detail: " (vim)"},
518+ {TabID: domain.TabStableID(sess.tabs[1].stableID), Name: "logs", Detail: " (sh)"},
519+ }, views[0].Tabs)
221 }520 }
222 521
223 func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) {522 func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) {
@@ -879,7 +1178,7 @@ func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *t
879 pickerModel: picker.New([]picker.SessionView{1178 pickerModel: picker.New([]picker.SessionView{
880 {ID: "selected", Name: "selected", Tabs: []picker.TabEntry{{Name: "one"}}, Active: 0},1179 {ID: "selected", Name: "selected", Tabs: []picker.TabEntry{{Name: "one"}}, Active: 0},
881 {ID: "inactive", Name: "inactive", Tabs: []picker.TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0},1180 {ID: "inactive", Name: "inactive", Tabs: []picker.TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0},
882- }, "selected", 0),1181+ }, picker.SelectionConfig{Mode: picker.SelectNavigationTab}),
883 }1182 }
884 1183
885 captureOverlayLayers(&state, snap, domain.PaletteConfig{})1184 captureOverlayLayers(&state, snap, domain.PaletteConfig{})
@@ -908,7 +1207,7 @@ func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *t
908 }1207 }
909 1208
910 func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testing.T) {1209 func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testing.T) {
911- model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0)1210+ model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
912 state := capturedRenderState{theme: themeui.BuiltinDark, styles: themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles}1211 state := capturedRenderState{theme: themeui.BuiltinDark, styles: themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles}
913 cases := []struct {1212 cases := []struct {
914 name string1213 name string
@@ -1160,6 +1459,48 @@ func TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut(t *t
1160 require.Same(t, from, ac.currentSession(), "a failed restore must leave the client on its origin session")1459 require.Same(t, from, ac.currentSession(), "a failed restore must leave the client on its origin session")
1161 }1460 }
1162 1461
1462+func TestPickerNavigationRefreshAfterSuccessfulDeleteSelectsAttachedActiveStableTab(t *testing.T) {
1463+ d, current, ac, _, currentReleases := newManualTabSession(t, 2)
1464+ defer releaseAll(currentReleases)
1465+ current.id, current.name, current.active = "current", "z-current", 1
1466+ current.tabs[0].stableID, current.tabs[1].stableID = "current-first", "current-active"
1467+ delete(d.sessions, domain.SessionID("manual"))
1468+ d.sessions[current.id] = current
1469+
1470+ otherPTY, releaseOther := newBlockingPTY(t)
1471+ defer releaseOther()
1472+ otherTab := newTestTabWithContext(otherPTY, current.ctx, current.cancel)
1473+ otherTab.stableID = "other-tab"
1474+ other := &session{id: "other", name: "a-other", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}}
1475+ d.sessions[other.id] = other
1476+
1477+ targetPTY, releaseTarget := newBlockingPTY(t)
1478+ defer releaseTarget()
1479+ targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel)
1480+ targetTab.stableID = "target-tab"
1481+ target := &session{id: "target", name: "m-target", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}
1482+ d.sessions[target.id] = target
1483+
1484+ d.enterPicker(current, ac)
1485+ d.handlePickerInput(ac, []byte("k"))
1486+ d.handlePickerInput(ac, []byte("k"))
1487+ ac.overlays.pickerMu.Lock()
1488+ selected, ok := ac.overlays.picker.Selected()
1489+ ac.overlays.pickerMu.Unlock()
1490+ require.True(t, ok)
1491+ require.Equal(t, target.id, selected.Session, "test must delete the selected non-attached session")
1492+
1493+ d.handlePickerInput(ac, []byte("x"))
1494+
1495+ ac.overlays.pickerMu.Lock()
1496+ selected, ok = ac.overlays.picker.Selected()
1497+ ac.overlays.pickerMu.Unlock()
1498+ require.True(t, ok)
1499+ require.Equal(t, current.id, selected.Session)
1500+ require.Equal(t, domain.TabStableID("current-active"), selected.TabID)
1501+ require.Equal(t, 1, selected.TabIndex)
1502+}
1503+
1163 func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t *testing.T) {1504 func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t *testing.T) {
1164 d, from, ac, sends, releases := newRecentNavigationTestSessions(t)1505 d, from, ac, sends, releases := newRecentNavigationTestSessions(t)
1165 defer releaseAll(releases)1506 defer releaseAll(releases)
@@ -1206,6 +1547,12 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
1206 p, release := newBlockingPTY(t)1547 p, release := newBlockingPTY(t)
1207 defer release()1548 defer release()
1208 d, from, ac, sends := newManualSessionWithPTYs(t, p)1549 d, from, ac, sends := newManualSessionWithPTYs(t, p)
1550+ otherPTY, releaseOther := newBlockingPTY(t)
1551+ defer releaseOther()
1552+ otherTab := newTestTabWithContext(otherPTY, from.ctx, from.cancel)
1553+ otherTab.stableID = "other-tab"
1554+ other := &session{id: "other", name: "a-other", ephemeral: true, ctx: from.ctx, cancel: func() {}, tabs: []*tab{otherTab}}
1555+ d.sessions[other.id] = other
1209 cause := errors.New("delete failed")1556 cause := errors.New("delete failed")
1210 store, state := newMockStore(t)1557 store, state := newMockStore(t)
1211 WithStore(t, store)(d)1558 WithStore(t, store)(d)
@@ -1236,4 +1583,15 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
1236 d.mu.Unlock()1583 d.mu.Unlock()
1237 require.True(t, retained, "failed deletion must retain the reserved name")1584 require.True(t, retained, "failed deletion must retain the reserved name")
1238 require.True(t, stopped.purging, "an uncertain deletion stays fenced from restore")1585 require.True(t, stopped.purging, "an uncertain deletion stays fenced from restore")
1586+
1587+ from.mu.Lock()
1588+ activeTab := from.tabs[from.active]
1589+ activeTabID := activeTab.stableID
1590+ from.mu.Unlock()
1591+ ac.overlays.pickerMu.Lock()
1592+ selected, selectedOK := ac.overlays.picker.Selected()
1593+ ac.overlays.pickerMu.Unlock()
1594+ require.True(t, selectedOK)
1595+ require.Equal(t, from.id, selected.Session, "failed x refresh resets navigation to the attached session")
1596+ require.Equal(t, domain.TabStableID(activeTabID), selected.TabID, "failed x refresh follows the attached session's stable active tab")
1239 }1597 }
M internal/usecase/picker/model.go +172 -41
@@ -17,6 +17,7 @@ const (
1717
1818 type SessionView struct {
1919 ID domain.SessionID
20+ Incarnation domain.IncarnationID
2021 Name string
2122 TargetName string
2223 Tabs []TabEntry
@@ -27,9 +28,33 @@ type SessionView struct {
2728
2829 // TabEntry is one tab row; Name is drawn emphasized, Detail muted.
2930 type TabEntry struct {
30- Name string // tab display name
31- Detail string // " (paneTitle)" or "", drawn muted
32- Attention bool // draw the attention marker right after Name, before Detail
31+ TabID domain.TabStableID // stable tab identity; independent of its current index
32+ Name string // tab display name
33+ Detail string // " (paneTitle)" or "", drawn muted
34+ Attention bool // draw the attention marker right after Name, before Detail
35+}
36+
37+type SelectionMode uint8
38+
39+const (
40+ SelectNavigationTab SelectionMode = iota
41+ SelectMovePaneTab
42+ SelectMoveTabSession
43+)
44+
45+type SourceFilter struct {
46+ Session domain.SessionID
47+ Incarnation domain.IncarnationID
48+ TabID domain.TabStableID
49+}
50+
51+// SelectionConfig describes which rows are selectable, which stable target
52+// should remain selected, and which source must not be offered as a move
53+// destination.
54+type SelectionConfig struct {
55+ Mode SelectionMode
56+ Current SourceFilter
57+ Source SourceFilter
3358 }
3459
3560 // RenderStyles are the styles Render uses to draw list rows. The zero value
@@ -59,10 +84,12 @@ func defaultRenderStyles() RenderStyles {
5984 }
6085
6186 type Target struct {
62- Session domain.SessionID
63- Name string
64- TabIndex int
65- Stopped bool
87+ Session domain.SessionID
88+ Incarnation domain.IncarnationID
89+ Name string
90+ TabID domain.TabStableID
91+ TabIndex int
92+ Stopped bool
6693 // ExpectedCreatedAt optionally pins this target to a particular named
6794 // session lifecycle. Callers that obtain a snapshot outside the daemon use
6895 // it to reject a same-name replacement at commit time.
@@ -88,44 +115,62 @@ type Layout struct {
88115 }
89116
90117 type Model struct {
118+ mode SelectionMode
91119 rows []row
92120 selected int
93121 }
94122
123+type rowKind uint8
124+
125+const (
126+ rowSession rowKind = iota
127+ rowTab
128+)
129+
130+func (k rowKind) rendersAsHeader() bool {
131+ return k == rowSession
132+}
133+
134+func (k rowKind) selectable(mode SelectionMode) bool {
135+ switch mode {
136+ case SelectNavigationTab, SelectMovePaneTab:
137+ return k == rowTab
138+ case SelectMoveTabSession:
139+ return k == rowSession
140+ default:
141+ return false
142+ }
143+}
144+
95145 type row struct {
96- header bool
97- dispName string // display name segment; bold on truecolor
98- detail string // " (paneTitle)" segment, muted; "" for headers
99- attention bool // draw the attention marker right after the name, before detail; tab rows only
100- session domain.SessionID
146+ kind rowKind
147+ dispName string // display name segment; bold on truecolor
148+ detail string // " (paneTitle)" segment, muted; "" for session rows
149+ attention bool // draw the attention marker right after the name, before detail; tab rows only
150+ session domain.SessionID
151+ incarnation domain.IncarnationID
101152 // targetName is the named-session lookup name threaded into Target;
102153 // distinct from dispName, which is what gets drawn.
103- targetName string
104- tabIndex int
105- stopped bool
106- expectedCreatedAt *int64
154+ targetName string
155+ tabID domain.TabStableID
156+ tabIndex int
157+ stopped bool
158+ expectedCreatedAt int64
159+ hasExpectedCreatedAt bool
107160 }
108161
109-func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model {
110- m := &Model{selected: -1}
162+func New(sessions []SessionView, config SelectionConfig) *Model {
163+ m := &Model{mode: config.Mode, selected: -1}
111164 activeSelection := -1
112165 for _, session := range sessions {
113- targetName := session.TargetName
114- if targetName == "" && (session.Stopped || session.ExpectedCreatedAt != nil) {
115- targetName = session.Name
116- }
117- m.rows = append(m.rows, row{header: true, dispName: session.Name, session: session.ID, targetName: targetName, tabIndex: -1, stopped: session.Stopped, expectedCreatedAt: session.ExpectedCreatedAt})
118- active := session.Active
119- if active < 0 || active >= len(session.Tabs) {
120- active = 0
121- }
122- for i, tab := range session.Tabs {
166+ sessionRows := rowsForSession(session, config)
167+ for _, pickerRow := range sessionRows {
123168 idx := len(m.rows)
124- m.rows = append(m.rows, row{dispName: tab.Name, detail: tab.Detail, attention: tab.Attention, session: session.ID, targetName: targetName, tabIndex: i, stopped: session.Stopped, expectedCreatedAt: session.ExpectedCreatedAt})
125- if session.ID == cur && i == curTab {
169+ m.rows = append(m.rows, pickerRow)
170+ if pickerRow.kind.selectable(config.Mode) && selectionMatches(pickerRow, config.Current, config.Mode) {
126171 m.selected = idx
127172 }
128- if activeSelection < 0 && i == active {
173+ if config.Mode == SelectNavigationTab && activeSelection < 0 && pickerRow.kind == rowTab && pickerRow.tabIndex == normalizedActive(session) {
129174 activeSelection = idx
130175 }
131176 }
@@ -134,7 +179,7 @@ func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model {
134179 m.selected = activeSelection
135180 }
136181 if m.selected < 0 {
137- m.selected = m.firstLeaf()
182+ m.selected = m.firstSelectable()
138183 }
139184 if m.selected < 0 && len(m.rows) > 0 {
140185 m.selected = 0
@@ -142,6 +187,81 @@ func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model {
142187 return m
143188 }
144189
190+// rowsForSession is the sole owner of mode-specific destination eligibility.
191+// The daemon supplies canonical lifecycle/tab snapshots without prefiltering.
192+func rowsForSession(session SessionView, config SelectionConfig) []row {
193+ if config.Mode != SelectNavigationTab && session.Stopped {
194+ return nil
195+ }
196+ if config.Mode == SelectMoveTabSession && (sourceMatchesSession(config.Source, session) || len(session.Tabs) == 0) {
197+ return nil
198+ }
199+
200+ targetName := session.TargetName
201+ if config.Mode == SelectNavigationTab && !session.Stopped && session.ExpectedCreatedAt == nil {
202+ targetName = ""
203+ } else if targetName == "" {
204+ targetName = session.Name
205+ }
206+ expectedCreatedAt, hasExpectedCreatedAt := int64Value(session.ExpectedCreatedAt)
207+ common := row{
208+ session: session.ID, incarnation: session.Incarnation, targetName: targetName,
209+ stopped: session.Stopped, expectedCreatedAt: expectedCreatedAt,
210+ hasExpectedCreatedAt: hasExpectedCreatedAt,
211+ }
212+ header := common
213+ header.kind, header.dispName, header.tabIndex = rowSession, session.Name, -1
214+ rows := []row{header}
215+ for i, tab := range session.Tabs {
216+ if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) {
217+ continue
218+ }
219+ tabRow := common
220+ tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention
221+ tabRow.tabID, tabRow.tabIndex = tab.TabID, i
222+ rows = append(rows, tabRow)
223+ }
224+ if config.Mode == SelectMovePaneTab && len(rows) == 1 {
225+ return nil
226+ }
227+ return rows
228+}
229+
230+func sourceMatchesSession(source SourceFilter, session SessionView) bool {
231+ if source.Session != session.ID {
232+ return false
233+ }
234+ return source.Incarnation == (domain.IncarnationID{}) || source.Incarnation == session.Incarnation
235+}
236+
237+func sourceMatchesTab(source SourceFilter, session SessionView, tab TabEntry) bool {
238+ return sourceMatchesSession(source, session) && source.TabID == tab.TabID
239+}
240+
241+func normalizedActive(session SessionView) int {
242+ if session.Active < 0 || session.Active >= len(session.Tabs) {
243+ return 0
244+ }
245+ return session.Active
246+}
247+
248+func selectionMatches(pickerRow row, current SourceFilter, mode SelectionMode) bool {
249+ if pickerRow.session != current.Session || (current.Incarnation != (domain.IncarnationID{}) && pickerRow.incarnation != current.Incarnation) {
250+ return false
251+ }
252+ if mode == SelectMoveTabSession {
253+ return pickerRow.kind == rowSession
254+ }
255+ return pickerRow.kind == rowTab && current.TabID != "" && pickerRow.tabID == current.TabID
256+}
257+
258+func int64Value(value *int64) (int64, bool) {
259+ if value == nil {
260+ return 0, false
261+ }
262+ return *value, true
263+}
264+
145265 func ChooseLayout(inner domain.Size) Layout {
146266 if inner.Cols <= 0 || inner.Rows <= 0 {
147267 return Layout{}
@@ -184,10 +304,13 @@ func (m *Model) Selected() (Target, bool) {
184304 return Target{}, false
185305 }
186306 r := m.rows[m.selected]
187- if r.header {
307+ if !r.kind.selectable(m.mode) {
188308 return Target{}, false
189309 }
190- return Target{Session: r.session, Name: r.targetName, TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAt}, true
310+ return Target{
311+ Session: r.session, Incarnation: r.incarnation, Name: r.targetName, TabID: r.tabID,
312+ TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAtPointer(),
313+ }, true
191314 }
192315
193316 func (m *Model) Clone() *Model {
@@ -222,27 +345,35 @@ func (m *Model) move(delta int) {
222345 if m == nil || len(m.rows) == 0 {
223346 return
224347 }
225- if m.selected < 0 || m.selected >= len(m.rows) || m.rows[m.selected].header {
226- m.selected = m.firstLeaf()
348+ if m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].kind.selectable(m.mode) {
349+ m.selected = m.firstSelectable()
227350 return
228351 }
229352 for i := m.selected + delta; i >= 0 && i < len(m.rows); i += delta {
230- if !m.rows[i].header {
353+ if m.rows[i].kind.selectable(m.mode) {
231354 m.selected = i
232355 return
233356 }
234357 }
235358 }
236359
237-func (m *Model) firstLeaf() int {
360+func (m *Model) firstSelectable() int {
238361 for i, r := range m.rows {
239- if !r.header {
362+ if r.kind.selectable(m.mode) {
240363 return i
241364 }
242365 }
243366 return -1
244367 }
245368
369+func (r row) expectedCreatedAtPointer() *int64 {
370+ if !r.hasExpectedCreatedAt {
371+ return nil
372+ }
373+ value := r.expectedCreatedAt
374+ return &value
375+}
376+
246377 // renderList draws each visible row as up to three segments: a name segment
247378 // (bold when styles came from a truecolor theme), a base-styled attention
248379 // marker right after the name, and a muted detail segment (tab rows only) —
@@ -268,13 +399,13 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
268399 ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})
269400
270401 name := r.dispName
271- if !r.header {
402+ if !r.kind.rendersAsHeader() {
272403 name = " " + name
273404 }
274405 name = ui.TruncateText(name, rect.Width)
275406 x := ui.DrawText(frame, rect.X, rect.Y+y, clipX, name, nameStyle)
276407
277- if r.header {
408+ if r.kind.rendersAsHeader() {
278409 if r.stopped {
279410 ui.DrawText(frame, x, rect.Y+y, clipX, " (stopped)", base)
280411 }
@@ -17,6 +17,7 @@ const (
17 17
18 type SessionView struct {18 type SessionView struct {
19 ID domain.SessionID19 ID domain.SessionID
20+ Incarnation domain.IncarnationID
20 Name string21 Name string
21 TargetName string22 TargetName string
22 Tabs []TabEntry23 Tabs []TabEntry
@@ -27,9 +28,33 @@ type SessionView struct {
27 28
28 // TabEntry is one tab row; Name is drawn emphasized, Detail muted.29 // TabEntry is one tab row; Name is drawn emphasized, Detail muted.
29 type TabEntry struct {30 type TabEntry struct {
30- Name string // tab display name31+ TabID domain.TabStableID // stable tab identity; independent of its current index
31- Detail string // " (paneTitle)" or "", drawn muted32+ Name string // tab display name
32- Attention bool // draw the attention marker right after Name, before Detail33+ Detail string // " (paneTitle)" or "", drawn muted
34+ Attention bool // draw the attention marker right after Name, before Detail
35+}
36+
37+type SelectionMode uint8
38+
39+const (
40+ SelectNavigationTab SelectionMode = iota
41+ SelectMovePaneTab
42+ SelectMoveTabSession
43+)
44+
45+type SourceFilter struct {
46+ Session domain.SessionID
47+ Incarnation domain.IncarnationID
48+ TabID domain.TabStableID
49+}
50+
51+// SelectionConfig describes which rows are selectable, which stable target
52+// should remain selected, and which source must not be offered as a move
53+// destination.
54+type SelectionConfig struct {
55+ Mode SelectionMode
56+ Current SourceFilter
57+ Source SourceFilter
33 }58 }
34 59
35 // RenderStyles are the styles Render uses to draw list rows. The zero value60 // RenderStyles are the styles Render uses to draw list rows. The zero value
@@ -59,10 +84,12 @@ func defaultRenderStyles() RenderStyles {
59 }84 }
60 85
61 type Target struct {86 type Target struct {
62- Session domain.SessionID87+ Session domain.SessionID
63- Name string88+ Incarnation domain.IncarnationID
64- TabIndex int89+ Name string
65- Stopped bool90+ TabID domain.TabStableID
91+ TabIndex int
92+ Stopped bool
66 // ExpectedCreatedAt optionally pins this target to a particular named93 // ExpectedCreatedAt optionally pins this target to a particular named
67 // session lifecycle. Callers that obtain a snapshot outside the daemon use94 // session lifecycle. Callers that obtain a snapshot outside the daemon use
68 // it to reject a same-name replacement at commit time.95 // it to reject a same-name replacement at commit time.
@@ -88,44 +115,62 @@ type Layout struct {
88 }115 }
89 116
90 type Model struct {117 type Model struct {
118+ mode SelectionMode
91 rows []row119 rows []row
92 selected int120 selected int
93 }121 }
94 122
123+type rowKind uint8
124+
125+const (
126+ rowSession rowKind = iota
127+ rowTab
128+)
129+
130+func (k rowKind) rendersAsHeader() bool {
131+ return k == rowSession
132+}
133+
134+func (k rowKind) selectable(mode SelectionMode) bool {
135+ switch mode {
136+ case SelectNavigationTab, SelectMovePaneTab:
137+ return k == rowTab
138+ case SelectMoveTabSession:
139+ return k == rowSession
140+ default:
141+ return false
142+ }
143+}
144+
95 type row struct {145 type row struct {
96- header bool146+ kind rowKind
97- dispName string // display name segment; bold on truecolor147+ dispName string // display name segment; bold on truecolor
98- detail string // " (paneTitle)" segment, muted; "" for headers148+ detail string // " (paneTitle)" segment, muted; "" for session rows
99- attention bool // draw the attention marker right after the name, before detail; tab rows only149+ attention bool // draw the attention marker right after the name, before detail; tab rows only
100- session domain.SessionID150+ session domain.SessionID
151+ incarnation domain.IncarnationID
101 // targetName is the named-session lookup name threaded into Target;152 // targetName is the named-session lookup name threaded into Target;
102 // distinct from dispName, which is what gets drawn.153 // distinct from dispName, which is what gets drawn.
103- targetName string154+ targetName string
104- tabIndex int155+ tabID domain.TabStableID
105- stopped bool156+ tabIndex int
106- expectedCreatedAt *int64157+ stopped bool
158+ expectedCreatedAt int64
159+ hasExpectedCreatedAt bool
107 }160 }
108 161
109-func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model {162+func New(sessions []SessionView, config SelectionConfig) *Model {
110- m := &Model{selected: -1}163+ m := &Model{mode: config.Mode, selected: -1}
111 activeSelection := -1164 activeSelection := -1
112 for _, session := range sessions {165 for _, session := range sessions {
113- targetName := session.TargetName166+ sessionRows := rowsForSession(session, config)
114- if targetName == "" && (session.Stopped || session.ExpectedCreatedAt != nil) {167+ for _, pickerRow := range sessionRows {
115- targetName = session.Name
116- }
117- m.rows = append(m.rows, row{header: true, dispName: session.Name, session: session.ID, targetName: targetName, tabIndex: -1, stopped: session.Stopped, expectedCreatedAt: session.ExpectedCreatedAt})
118- active := session.Active
119- if active < 0 || active >= len(session.Tabs) {
120- active = 0
121- }
122- for i, tab := range session.Tabs {
123 idx := len(m.rows)168 idx := len(m.rows)
124- m.rows = append(m.rows, row{dispName: tab.Name, detail: tab.Detail, attention: tab.Attention, session: session.ID, targetName: targetName, tabIndex: i, stopped: session.Stopped, expectedCreatedAt: session.ExpectedCreatedAt})169+ m.rows = append(m.rows, pickerRow)
125- if session.ID == cur && i == curTab {170+ if pickerRow.kind.selectable(config.Mode) && selectionMatches(pickerRow, config.Current, config.Mode) {
126 m.selected = idx171 m.selected = idx
127 }172 }
128- if activeSelection < 0 && i == active {173+ if config.Mode == SelectNavigationTab && activeSelection < 0 && pickerRow.kind == rowTab && pickerRow.tabIndex == normalizedActive(session) {
129 activeSelection = idx174 activeSelection = idx
130 }175 }
131 }176 }
@@ -134,7 +179,7 @@ func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model {
134 m.selected = activeSelection179 m.selected = activeSelection
135 }180 }
136 if m.selected < 0 {181 if m.selected < 0 {
137- m.selected = m.firstLeaf()182+ m.selected = m.firstSelectable()
138 }183 }
139 if m.selected < 0 && len(m.rows) > 0 {184 if m.selected < 0 && len(m.rows) > 0 {
140 m.selected = 0185 m.selected = 0
@@ -142,6 +187,81 @@ func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model {
142 return m187 return m
143 }188 }
144 189
190+// rowsForSession is the sole owner of mode-specific destination eligibility.
191+// The daemon supplies canonical lifecycle/tab snapshots without prefiltering.
192+func rowsForSession(session SessionView, config SelectionConfig) []row {
193+ if config.Mode != SelectNavigationTab && session.Stopped {
194+ return nil
195+ }
196+ if config.Mode == SelectMoveTabSession && (sourceMatchesSession(config.Source, session) || len(session.Tabs) == 0) {
197+ return nil
198+ }
199+
200+ targetName := session.TargetName
201+ if config.Mode == SelectNavigationTab && !session.Stopped && session.ExpectedCreatedAt == nil {
202+ targetName = ""
203+ } else if targetName == "" {
204+ targetName = session.Name
205+ }
206+ expectedCreatedAt, hasExpectedCreatedAt := int64Value(session.ExpectedCreatedAt)
207+ common := row{
208+ session: session.ID, incarnation: session.Incarnation, targetName: targetName,
209+ stopped: session.Stopped, expectedCreatedAt: expectedCreatedAt,
210+ hasExpectedCreatedAt: hasExpectedCreatedAt,
211+ }
212+ header := common
213+ header.kind, header.dispName, header.tabIndex = rowSession, session.Name, -1
214+ rows := []row{header}
215+ for i, tab := range session.Tabs {
216+ if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) {
217+ continue
218+ }
219+ tabRow := common
220+ tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention
221+ tabRow.tabID, tabRow.tabIndex = tab.TabID, i
222+ rows = append(rows, tabRow)
223+ }
224+ if config.Mode == SelectMovePaneTab && len(rows) == 1 {
225+ return nil
226+ }
227+ return rows
228+}
229+
230+func sourceMatchesSession(source SourceFilter, session SessionView) bool {
231+ if source.Session != session.ID {
232+ return false
233+ }
234+ return source.Incarnation == (domain.IncarnationID{}) || source.Incarnation == session.Incarnation
235+}
236+
237+func sourceMatchesTab(source SourceFilter, session SessionView, tab TabEntry) bool {
238+ return sourceMatchesSession(source, session) && source.TabID == tab.TabID
239+}
240+
241+func normalizedActive(session SessionView) int {
242+ if session.Active < 0 || session.Active >= len(session.Tabs) {
243+ return 0
244+ }
245+ return session.Active
246+}
247+
248+func selectionMatches(pickerRow row, current SourceFilter, mode SelectionMode) bool {
249+ if pickerRow.session != current.Session || (current.Incarnation != (domain.IncarnationID{}) && pickerRow.incarnation != current.Incarnation) {
250+ return false
251+ }
252+ if mode == SelectMoveTabSession {
253+ return pickerRow.kind == rowSession
254+ }
255+ return pickerRow.kind == rowTab && current.TabID != "" && pickerRow.tabID == current.TabID
256+}
257+
258+func int64Value(value *int64) (int64, bool) {
259+ if value == nil {
260+ return 0, false
261+ }
262+ return *value, true
263+}
264+
145 func ChooseLayout(inner domain.Size) Layout {265 func ChooseLayout(inner domain.Size) Layout {
146 if inner.Cols <= 0 || inner.Rows <= 0 {266 if inner.Cols <= 0 || inner.Rows <= 0 {
147 return Layout{}267 return Layout{}
@@ -184,10 +304,13 @@ func (m *Model) Selected() (Target, bool) {
184 return Target{}, false304 return Target{}, false
185 }305 }
186 r := m.rows[m.selected]306 r := m.rows[m.selected]
187- if r.header {307+ if !r.kind.selectable(m.mode) {
188 return Target{}, false308 return Target{}, false
189 }309 }
190- return Target{Session: r.session, Name: r.targetName, TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAt}, true310+ return Target{
311+ Session: r.session, Incarnation: r.incarnation, Name: r.targetName, TabID: r.tabID,
312+ TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAtPointer(),
313+ }, true
191 }314 }
192 315
193 func (m *Model) Clone() *Model {316 func (m *Model) Clone() *Model {
@@ -222,27 +345,35 @@ func (m *Model) move(delta int) {
222 if m == nil || len(m.rows) == 0 {345 if m == nil || len(m.rows) == 0 {
223 return346 return
224 }347 }
225- if m.selected < 0 || m.selected >= len(m.rows) || m.rows[m.selected].header {348+ if m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].kind.selectable(m.mode) {
226- m.selected = m.firstLeaf()349+ m.selected = m.firstSelectable()
227 return350 return
228 }351 }
229 for i := m.selected + delta; i >= 0 && i < len(m.rows); i += delta {352 for i := m.selected + delta; i >= 0 && i < len(m.rows); i += delta {
230- if !m.rows[i].header {353+ if m.rows[i].kind.selectable(m.mode) {
231 m.selected = i354 m.selected = i
232 return355 return
233 }356 }
234 }357 }
235 }358 }
236 359
237-func (m *Model) firstLeaf() int {360+func (m *Model) firstSelectable() int {
238 for i, r := range m.rows {361 for i, r := range m.rows {
239- if !r.header {362+ if r.kind.selectable(m.mode) {
240 return i363 return i
241 }364 }
242 }365 }
243 return -1366 return -1
244 }367 }
245 368
369+func (r row) expectedCreatedAtPointer() *int64 {
370+ if !r.hasExpectedCreatedAt {
371+ return nil
372+ }
373+ value := r.expectedCreatedAt
374+ return &value
375+}
376+
246 // renderList draws each visible row as up to three segments: a name segment377 // renderList draws each visible row as up to three segments: a name segment
247 // (bold when styles came from a truecolor theme), a base-styled attention378 // (bold when styles came from a truecolor theme), a base-styled attention
248 // marker right after the name, and a muted detail segment (tab rows only) —379 // marker right after the name, and a muted detail segment (tab rows only) —
@@ -268,13 +399,13 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
268 ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})399 ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})
269 400
270 name := r.dispName401 name := r.dispName
271- if !r.header {402+ if !r.kind.rendersAsHeader() {
272 name = " " + name403 name = " " + name
273 }404 }
274 name = ui.TruncateText(name, rect.Width)405 name = ui.TruncateText(name, rect.Width)
275 x := ui.DrawText(frame, rect.X, rect.Y+y, clipX, name, nameStyle)406 x := ui.DrawText(frame, rect.X, rect.Y+y, clipX, name, nameStyle)
276 407
277- if r.header {408+ if r.kind.rendersAsHeader() {
278 if r.stopped {409 if r.stopped {
279 ui.DrawText(frame, x, rect.Y+y, clipX, " (stopped)", base)410 ui.DrawText(frame, x, rect.Y+y, clipX, " (stopped)", base)
280 }411 }
M internal/usecase/picker/model_test.go +237 -26
@@ -1,6 +1,7 @@
11 package picker
22
33 import (
4+ "reflect"
45 "testing"
56
67 "github.com/bnema/vev/internal/domain"
@@ -10,15 +11,26 @@ import (
1011 "github.com/stretchr/testify/require"
1112 )
1213
14+var (
15+ _ domain.TabStableID = TabEntry{}.TabID
16+ _ domain.TabStableID = Target{}.TabID
17+ _ domain.TabStableID = SourceFilter{}.TabID
18+)
19+
20+func TestStableIdentityTypesAreDistinct(t *testing.T) {
21+ require.NotEqual(t, reflect.TypeFor[domain.TabStableID](), reflect.TypeFor[domain.PaneStableID]())
22+ require.NotEqual(t, reflect.TypeFor[domain.TabStableID](), reflect.TypeFor[domain.TabID]())
23+}
24+
1325 func TestNewFlattensAndSelectsCurrentTab(t *testing.T) {
1426 m := New([]SessionView{
15- {ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 0},
16- {ID: "s2", Name: "two", Tabs: []TabEntry{{Name: "api"}}, Active: 0},
17- }, "s1", 1)
27+ {ID: "s1", Name: "one", Tabs: []TabEntry{{TabID: "shell", Name: "shell"}, {TabID: "logs", Name: "logs"}}, Active: 0},
28+ {ID: "s2", Name: "two", Tabs: []TabEntry{{TabID: "api", Name: "api"}}, Active: 0},
29+ }, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "s1", TabID: "logs"}})
1830
1931 got, ok := m.Selected()
2032 require.True(t, ok)
21- require.Equal(t, Target{Session: "s1", TabIndex: 1}, got)
33+ require.Equal(t, Target{Session: "s1", TabID: "logs", TabIndex: 1}, got)
2234 frame := m.Render(domain.Size{Cols: 20, Rows: 5}, Preview{})
2335 require.Equal(t, 'o', frame.At(0, 0).Rune)
2436 require.Equal(t, ' ', frame.At(0, 2).Rune)
@@ -27,12 +39,12 @@ func TestNewFlattensAndSelectsCurrentTab(t *testing.T) {
2739 }
2840
2941 func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) {
30- m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 1}}, "missing", 0)
42+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 1}}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "missing"}})
3143 got, ok := m.Selected()
3244 require.True(t, ok)
3345 require.Equal(t, Target{Session: "s1", TabIndex: 1}, got)
3446
35- m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}}, Active: 4}}, "missing", 0)
47+ m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}}, Active: 4}}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "missing"}})
3648 got, ok = m.Selected()
3749 require.True(t, ok)
3850 require.Equal(t, Target{Session: "s1", TabIndex: 0}, got)
@@ -42,7 +54,7 @@ func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) {
4254 m := New([]SessionView{
4355 {ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a"}, {Name: "b"}}, Active: 0},
4456 {ID: "s2", Name: "two", Tabs: []TabEntry{{Name: "c"}}, Active: 0},
45- }, "s1", 0)
57+ }, SelectionConfig{Mode: SelectNavigationTab})
4658
4759 m.Up()
4860 got, ok := m.Selected()
@@ -66,6 +78,205 @@ func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) {
6678 require.Equal(t, Target{Session: "s1", TabIndex: 1}, got, "up skips second session header")
6779 }
6880
81+func TestModelConstructsTargetsForSelectionModes(t *testing.T) {
82+ incarnationOne := domain.IncarnationID{1}
83+ incarnationTwo := domain.IncarnationID{2}
84+ sessions := []SessionView{
85+ {ID: "s1", Name: "one", TargetName: "one", Incarnation: incarnationOne, Tabs: []TabEntry{{TabID: "t1", Name: "shell"}, {TabID: "t2", Name: "logs"}}, Active: 1},
86+ {ID: "s2", Name: "two", TargetName: "two", Incarnation: incarnationTwo, Tabs: []TabEntry{{TabID: "t3", Name: "api"}}, Active: 0},
87+ }
88+ tests := []struct {
89+ name string
90+ config SelectionConfig
91+ want Target
92+ }{
93+ {
94+ name: "navigation selects tab row",
95+ config: SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "s1", TabID: "t2"}},
96+ want: Target{Session: "s1", Incarnation: incarnationOne, TabID: "t2", TabIndex: 1},
97+ },
98+ {
99+ name: "move pane selects eligible tab row",
100+ config: SelectionConfig{Mode: SelectMovePaneTab, Current: SourceFilter{Session: "s1", TabID: "t1"}, Source: SourceFilter{Session: "s1", TabID: "t2"}},
101+ want: Target{Session: "s1", Incarnation: incarnationOne, Name: "one", TabID: "t1", TabIndex: 0},
102+ },
103+ {
104+ name: "move tab selects destination session header",
105+ config: SelectionConfig{Mode: SelectMoveTabSession, Current: SourceFilter{Session: "s2"}, Source: SourceFilter{Session: "s1"}},
106+ want: Target{Session: "s2", Incarnation: incarnationTwo, Name: "two", TabIndex: -1},
107+ },
108+ }
109+ for _, tt := range tests {
110+ t.Run(tt.name, func(t *testing.T) {
111+ model := New(sessions, tt.config)
112+
113+ got, ok := model.Selected()
114+ require.True(t, ok)
115+ require.Equal(t, tt.want, got)
116+ })
117+ }
118+}
119+
120+func TestModelMoveModesApplyExactDestinationFiltering(t *testing.T) {
121+ incarnation := domain.IncarnationID{1}
122+ sessions := []SessionView{
123+ {ID: "source", Incarnation: incarnation, Name: "source", Tabs: []TabEntry{{TabID: "source-tab", Name: "source"}, {TabID: "other-tab", Name: "other"}}},
124+ {ID: "only-source", Incarnation: incarnation, Name: "only-source", Tabs: []TabEntry{{TabID: "only-tab", Name: "only"}}},
125+ {ID: "destination", Incarnation: incarnation, Name: "destination", Tabs: []TabEntry{{TabID: "destination-tab", Name: "destination"}}},
126+ {ID: "empty", Incarnation: incarnation, Name: "empty"},
127+ {ID: "stopped", Incarnation: incarnation, Name: "stopped", Stopped: true, Tabs: []TabEntry{{TabID: "stopped-tab", Name: "stopped"}}},
128+ }
129+ type rowIdentity struct {
130+ kind rowKind
131+ session domain.SessionID
132+ tab domain.TabStableID
133+ }
134+ identities := func(model *Model) []rowIdentity {
135+ got := make([]rowIdentity, 0, len(model.rows))
136+ for _, pickerRow := range model.rows {
137+ got = append(got, rowIdentity{kind: pickerRow.kind, session: pickerRow.session, tab: pickerRow.tabID})
138+ }
139+ return got
140+ }
141+
142+ paneModel := New(sessions, SelectionConfig{Mode: SelectMovePaneTab, Source: SourceFilter{
143+ Session: "only-source", Incarnation: incarnation, TabID: "only-tab",
144+ }})
145+ require.Equal(t, []rowIdentity{
146+ {kind: rowSession, session: "source"},
147+ {kind: rowTab, session: "source", tab: "source-tab"},
148+ {kind: rowTab, session: "source", tab: "other-tab"},
149+ {kind: rowSession, session: "destination"},
150+ {kind: rowTab, session: "destination", tab: "destination-tab"},
151+ }, identities(paneModel))
152+
153+ tabModel := New(sessions, SelectionConfig{Mode: SelectMoveTabSession, Source: SourceFilter{
154+ Session: "source", Incarnation: incarnation,
155+ }})
156+ require.Equal(t, []rowIdentity{
157+ {kind: rowSession, session: "only-source"},
158+ {kind: rowTab, session: "only-source", tab: "only-tab"},
159+ {kind: rowSession, session: "destination"},
160+ {kind: rowTab, session: "destination", tab: "destination-tab"},
161+ }, identities(tabModel))
162+
163+ replacementIncarnation := domain.IncarnationID{2}
164+ replacementModel := New([]SessionView{
165+ {ID: "same", Incarnation: incarnation, Tabs: []TabEntry{{TabID: "same-tab"}}},
166+ {ID: "same", Incarnation: replacementIncarnation, Tabs: []TabEntry{{TabID: "same-tab"}}},
167+ }, SelectionConfig{Mode: SelectMovePaneTab, Source: SourceFilter{
168+ Session: "same", Incarnation: incarnation, TabID: "same-tab",
169+ }})
170+ require.Len(t, replacementModel.rows, 2, "only the exact source lifecycle is filtered")
171+ require.Equal(t, replacementIncarnation, replacementModel.rows[0].incarnation)
172+ require.Equal(t, replacementIncarnation, replacementModel.rows[1].incarnation)
173+}
174+
175+func TestRowKindDefinesRenderingAndSelectability(t *testing.T) {
176+ tests := []struct {
177+ name string
178+ kind rowKind
179+ mode SelectionMode
180+ header bool
181+ selectable bool
182+ }{
183+ {name: "navigation session", kind: rowSession, mode: SelectNavigationTab, header: true},
184+ {name: "navigation tab", kind: rowTab, mode: SelectNavigationTab, selectable: true},
185+ {name: "move pane session", kind: rowSession, mode: SelectMovePaneTab, header: true},
186+ {name: "move pane tab", kind: rowTab, mode: SelectMovePaneTab, selectable: true},
187+ {name: "move tab session", kind: rowSession, mode: SelectMoveTabSession, header: true, selectable: true},
188+ {name: "move tab tab", kind: rowTab, mode: SelectMoveTabSession},
189+ }
190+ for _, tt := range tests {
191+ t.Run(tt.name, func(t *testing.T) {
192+ require.Equal(t, tt.header, tt.kind.rendersAsHeader())
193+ require.Equal(t, tt.selectable, tt.kind.selectable(tt.mode))
194+ })
195+ }
196+}
197+
198+func TestModelMovePaneFallsBackToFirstEligibleTab(t *testing.T) {
199+ model := New([]SessionView{
200+ {ID: "source", Name: "source", Active: 0, Tabs: []TabEntry{{TabID: "source-tab", Name: "source"}, {TabID: "sibling", Name: "sibling"}}},
201+ {ID: "destination", Name: "destination", Active: 0, Tabs: []TabEntry{{TabID: "destination-tab", Name: "destination"}}},
202+ }, SelectionConfig{Mode: SelectMovePaneTab, Source: SourceFilter{Session: "source", TabID: "source-tab"}})
203+
204+ got, ok := model.Selected()
205+ require.True(t, ok)
206+ require.Equal(t, domain.TabStableID("sibling"), got.TabID)
207+}
208+
209+func TestModelMoveNavigationSkipsRowsNotSelectableForMode(t *testing.T) {
210+ sessions := []SessionView{
211+ {ID: "s1", Name: "one", Tabs: []TabEntry{{TabID: "t1", Name: "one"}}},
212+ {ID: "s2", Name: "two", Tabs: []TabEntry{{TabID: "t2", Name: "two"}}},
213+ }
214+
215+ paneModel := New(sessions, SelectionConfig{Mode: SelectMovePaneTab, Current: SourceFilter{Session: "s1", TabID: "t1"}})
216+ paneModel.Down()
217+ got, ok := paneModel.Selected()
218+ require.True(t, ok)
219+ require.Equal(t, domain.TabStableID("t2"), got.TabID, "move-pane navigation skips the second session header")
220+
221+ tabModel := New(sessions, SelectionConfig{Mode: SelectMoveTabSession, Current: SourceFilter{Session: "s1"}})
222+ tabModel.Down()
223+ got, ok = tabModel.Selected()
224+ require.True(t, ok)
225+ require.Equal(t, domain.SessionID("s2"), got.Session, "move-tab navigation skips tab rows")
226+}
227+
228+func TestModelSelectedKeepsImmutableLifecycleAndStableTabAcrossRefresh(t *testing.T) {
229+ createdAt := int64(41)
230+ views := []SessionView{{
231+ ID: "same-id", Name: "same-name", TargetName: "same-name", Incarnation: domain.IncarnationID{1}, ExpectedCreatedAt: &createdAt,
232+ Tabs: []TabEntry{{TabID: "first", Name: "first"}, {TabID: "stable", Name: "stable"}}, Active: 1,
233+ }}
234+ model := New(views, SelectionConfig{Mode: SelectMovePaneTab, Current: SourceFilter{Session: "same-id", TabID: "stable"}})
235+
236+ views[0].Incarnation = domain.IncarnationID{9}
237+ views[0].TargetName = "replacement"
238+ views[0].Tabs[1].TabID = "replacement-tab"
239+ createdAt = 99
240+ got, ok := model.Selected()
241+ require.True(t, ok)
242+ require.Equal(t, Target{
243+ Session: "same-id", Incarnation: domain.IncarnationID{1}, Name: "same-name", TabID: "stable", TabIndex: 1,
244+ ExpectedCreatedAt: int64Pointer(41),
245+ }, got, "model target remains bound to the lifecycle captured during construction")
246+
247+ refreshed := New([]SessionView{{
248+ ID: "same-id", Name: "same-name", TargetName: "same-name", Incarnation: domain.IncarnationID{1},
249+ Tabs: []TabEntry{{TabID: "stable", Name: "stable"}, {TabID: "first", Name: "first"}}, Active: 1,
250+ }}, SelectionConfig{Mode: SelectMovePaneTab, Current: SourceFilter{Session: got.Session, TabID: got.TabID}})
251+ got, ok = refreshed.Selected()
252+ require.True(t, ok)
253+ require.Equal(t, domain.TabStableID("stable"), got.TabID)
254+ require.Equal(t, 0, got.TabIndex, "mutable index follows the stable tab after refresh")
255+}
256+
257+func TestModelClonePreservesModeAndImmutableTarget(t *testing.T) {
258+ createdAt := int64(7)
259+ model := New([]SessionView{{
260+ ID: "s", Name: "session", TargetName: "session", Incarnation: domain.IncarnationID{3}, ExpectedCreatedAt: &createdAt,
261+ Tabs: []TabEntry{{TabID: "tab", Name: "tab"}},
262+ }}, SelectionConfig{Mode: SelectMovePaneTab})
263+ clone := model.Clone()
264+
265+ require.Equal(t, SelectMovePaneTab, clone.mode)
266+ got, ok := clone.Selected()
267+ require.True(t, ok)
268+ *got.ExpectedCreatedAt = 100
269+ gotAgain, ok := clone.Selected()
270+ require.True(t, ok)
271+ require.Equal(t, int64(7), *gotAgain.ExpectedCreatedAt, "returned targets cannot mutate the cloned model locator")
272+ require.Equal(t, domain.TabStableID("tab"), gotAgain.TabID)
273+ require.Equal(t, domain.IncarnationID{3}, gotAgain.Incarnation)
274+}
275+
276+func int64Pointer(value int64) *int64 {
277+ return new(value)
278+}
279+
69280 func TestChooseLayoutResponsiveBoundaries(t *testing.T) {
70281 tests := []struct {
71282 name string
@@ -113,7 +324,7 @@ func TestChooseLayoutResponsiveBoundaries(t *testing.T) {
113324 }
114325
115326 func TestRenderDrawsCustomOrientedSeparators(t *testing.T) {
116- m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0)
327+ m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
117328 separator := renderer.Style{Foreground: 8, Attrs: renderer.AttrDim}
118329 styles := RenderStyles{Separator: separator}
119330 tests := []struct {
@@ -140,7 +351,7 @@ func TestRenderDrawsCustomOrientedSeparators(t *testing.T) {
140351 }
141352
142353 func TestRenderListOnlyOmitsSeparatorAndPreview(t *testing.T) {
143- m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0)
354+ m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
144355 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}})
145356
146357 require.Equal(t, ' ', frame.At(22, 10).Rune)
@@ -148,7 +359,7 @@ func TestRenderListOnlyOmitsSeparatorAndPreview(t *testing.T) {
148359 }
149360
150361 func TestRenderPreviewAnchorsOversizedSourceToFinalRows(t *testing.T) {
151- m := New(nil, "", 0)
362+ m := New(nil, SelectionConfig{Mode: SelectNavigationTab})
152363 preview := Preview{Width: 24, Height: 9, Rows: previewRows(24, "abcdefghi")}
153364
154365 frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview)
@@ -157,7 +368,7 @@ func TestRenderPreviewAnchorsOversizedSourceToFinalRows(t *testing.T) {
157368 }
158369
159370 func TestRenderPreviewBottomPlacesShortSource(t *testing.T) {
160- m := New(nil, "", 0)
371+ m := New(nil, SelectionConfig{Mode: SelectNavigationTab})
161372 preview := Preview{Width: 24, Height: 1, Rows: previewRows(24, "z")}
162373
163374 frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview)
@@ -166,7 +377,7 @@ func TestRenderPreviewBottomPlacesShortSource(t *testing.T) {
166377 }
167378
168379 func TestRenderAttentionMarkerSmokeWithResponsiveLayout(t *testing.T) {
169- m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab", Attention: true}}, Active: 0}}, "s", 0)
380+ m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab", Attention: true}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
170381 frame := m.Render(domain.Size{Cols: 69, Rows: 4}, Preview{})
171382
172383 require.Equal(t, rune(ui.AttentionGlyph), frame.At(6, 1).Rune)
@@ -186,17 +397,17 @@ func previewRows(width int, labels string) [][]renderer.Cell {
186397
187398 func TestSelectedMapping(t *testing.T) {
188399 m := New([]SessionView{
189- {ID: "alpha", Name: "alpha", Tabs: []TabEntry{{Name: "one"}}, Active: 0},
190- {ID: "beta", Name: "beta", Tabs: []TabEntry{{Name: "two"}, {Name: "three"}}, Active: 0},
191- }, "beta", 1)
400+ {ID: "alpha", Name: "alpha", Tabs: []TabEntry{{TabID: "one", Name: "one"}}, Active: 0},
401+ {ID: "beta", Name: "beta", Tabs: []TabEntry{{TabID: "two", Name: "two"}, {TabID: "three", Name: "three"}}, Active: 0},
402+ }, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "beta", TabID: "three"}})
192403
193404 got, ok := m.Selected()
194405 require.True(t, ok)
195- require.Equal(t, Target{Session: "beta", TabIndex: 1}, got)
406+ require.Equal(t, Target{Session: "beta", TabID: "three", TabIndex: 1}, got)
196407 }
197408
198409 func TestStoppedSessionSelectableAndRendered(t *testing.T) {
199- m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, "", 0)
410+ m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, SelectionConfig{Mode: SelectNavigationTab})
200411 got, ok := m.Selected()
201412 require.True(t, ok)
202413 require.Equal(t, Target{Session: "stopped:work", Name: "work", TabIndex: 0, Stopped: true}, got)
@@ -206,7 +417,7 @@ func TestStoppedSessionSelectableAndRendered(t *testing.T) {
206417 }
207418
208419 func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
209- m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0)
420+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
210421 preview := Preview{
211422 Width: 25,
212423 Height: 1,
@@ -226,7 +437,7 @@ func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
226437 }
227438
228439 func TestRenderListScrollsSelectionIntoView(t *testing.T) {
229- m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a"}, {Name: "b"}, {Name: "c"}, {Name: "d"}, {Name: "e"}, {Name: "f"}}, Active: 0}}, "s1", 0)
440+ 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})
230441 for range 5 {
231442 m.Down()
232443 }
@@ -238,7 +449,7 @@ func TestRenderListScrollsSelectionIntoView(t *testing.T) {
238449 }
239450
240451 func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) {
241- m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a-really-long-focused-pane-tab-label"}}, Active: 0}}, "s1", 0)
452+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a-really-long-focused-pane-tab-label"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
242453
243454 frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{})
244455
@@ -249,7 +460,7 @@ func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) {
249460 }
250461
251462 func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) {
252- m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0)
463+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
253464 preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}
254465
255466 frame := m.Render(domain.Size{Cols: 23, Rows: 11}, preview)
@@ -273,7 +484,7 @@ func TestRenderListDrawsNameAndDetailSegmentsWithDistinctStyles(t *testing.T) {
273484 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{
274485 {Name: "alpha", Detail: " (running)"},
275486 {Name: "beta", Detail: " (idle)", Attention: true},
276- }, Active: 0}}, "s1", 0)
487+ }, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
277488
278489 frame := m.Render(domain.Size{Cols: 30, Rows: 5}, Preview{}, styles)
279490
@@ -303,7 +514,7 @@ func TestRenderListDrawsNameAndDetailSegmentsWithDistinctStyles(t *testing.T) {
303514 func TestRenderListTruncatesDetailBeforeName(t *testing.T) {
304515 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{
305516 {Name: "short-name", Detail: " (a very long detail text)"},
306- }, Active: 0}}, "s1", 0)
517+ }, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
307518
308519 frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{})
309520 layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5})
@@ -320,7 +531,7 @@ func TestRenderListTruncatesDetailBeforeName(t *testing.T) {
320531 func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) {
321532 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{
322533 {Name: "a-really-long-focused-pane-tab-label", Detail: " (detail)"},
323- }, Active: 0}}, "s1", 0)
534+ }, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
324535
325536 frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{})
326537 layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5})
@@ -335,7 +546,7 @@ func cell(r rune) renderer.Cell {
335546 }
336547
337548 func TestRenderStylesFillBackgroundRowsAndSelection(t *testing.T) {
338- m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0)
549+ m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
339550 background := renderer.Style{Foreground: 1, Background: 2}
340551 base := renderer.Style{Foreground: 3, Background: 4}
341552 selection := renderer.Style{Foreground: 5, Background: 6}
@@ -383,7 +594,7 @@ func TestPickerRowsKeepTerminalBackgroundAcrossAccentFallbacks(t *testing.T) {
383594 model := New([]SessionView{
384595 {ID: "selected", Name: "selected", Tabs: []TabEntry{{Name: "one"}}, Active: 0},
385596 {ID: "inactive", Name: "inactive", Tabs: []TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0},
386- }, "selected", 0)
597+ }, SelectionConfig{Mode: SelectNavigationTab})
387598 for _, tt := range tests {
388599 t.Run(tt.name, func(t *testing.T) {
389600 styles := themeui.Resolve(tt.theme, tt.policy).Styles
@@ -1,6 +1,7 @@
1 package picker1 package picker
2 2
3 import (3 import (
4+ "reflect"
4 "testing"5 "testing"
5 6
6 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
@@ -10,15 +11,26 @@ import (
10 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
11 )12 )
12 13
14+var (
15+ _ domain.TabStableID = TabEntry{}.TabID
16+ _ domain.TabStableID = Target{}.TabID
17+ _ domain.TabStableID = SourceFilter{}.TabID
18+)
19+
20+func TestStableIdentityTypesAreDistinct(t *testing.T) {
21+ require.NotEqual(t, reflect.TypeFor[domain.TabStableID](), reflect.TypeFor[domain.PaneStableID]())
22+ require.NotEqual(t, reflect.TypeFor[domain.TabStableID](), reflect.TypeFor[domain.TabID]())
23+}
24+
13 func TestNewFlattensAndSelectsCurrentTab(t *testing.T) {25 func TestNewFlattensAndSelectsCurrentTab(t *testing.T) {
14 m := New([]SessionView{26 m := New([]SessionView{
15- {ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 0},27+ {ID: "s1", Name: "one", Tabs: []TabEntry{{TabID: "shell", Name: "shell"}, {TabID: "logs", Name: "logs"}}, Active: 0},
16- {ID: "s2", Name: "two", Tabs: []TabEntry{{Name: "api"}}, Active: 0},28+ {ID: "s2", Name: "two", Tabs: []TabEntry{{TabID: "api", Name: "api"}}, Active: 0},
17- }, "s1", 1)29+ }, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "s1", TabID: "logs"}})
18 30
19 got, ok := m.Selected()31 got, ok := m.Selected()
20 require.True(t, ok)32 require.True(t, ok)
21- require.Equal(t, Target{Session: "s1", TabIndex: 1}, got)33+ require.Equal(t, Target{Session: "s1", TabID: "logs", TabIndex: 1}, got)
22 frame := m.Render(domain.Size{Cols: 20, Rows: 5}, Preview{})34 frame := m.Render(domain.Size{Cols: 20, Rows: 5}, Preview{})
23 require.Equal(t, 'o', frame.At(0, 0).Rune)35 require.Equal(t, 'o', frame.At(0, 0).Rune)
24 require.Equal(t, ' ', frame.At(0, 2).Rune)36 require.Equal(t, ' ', frame.At(0, 2).Rune)
@@ -27,12 +39,12 @@ func TestNewFlattensAndSelectsCurrentTab(t *testing.T) {
27 }39 }
28 40
29 func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) {41 func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) {
30- m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 1}}, "missing", 0)42+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}, {Name: "logs"}}, Active: 1}}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "missing"}})
31 got, ok := m.Selected()43 got, ok := m.Selected()
32 require.True(t, ok)44 require.True(t, ok)
33 require.Equal(t, Target{Session: "s1", TabIndex: 1}, got)45 require.Equal(t, Target{Session: "s1", TabIndex: 1}, got)
34 46
35- m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}}, Active: 4}}, "missing", 0)47+ m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "shell"}}, Active: 4}}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "missing"}})
36 got, ok = m.Selected()48 got, ok = m.Selected()
37 require.True(t, ok)49 require.True(t, ok)
38 require.Equal(t, Target{Session: "s1", TabIndex: 0}, got)50 require.Equal(t, Target{Session: "s1", TabIndex: 0}, got)
@@ -42,7 +54,7 @@ func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) {
42 m := New([]SessionView{54 m := New([]SessionView{
43 {ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a"}, {Name: "b"}}, Active: 0},55 {ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a"}, {Name: "b"}}, Active: 0},
44 {ID: "s2", Name: "two", Tabs: []TabEntry{{Name: "c"}}, Active: 0},56 {ID: "s2", Name: "two", Tabs: []TabEntry{{Name: "c"}}, Active: 0},
45- }, "s1", 0)57+ }, SelectionConfig{Mode: SelectNavigationTab})
46 58
47 m.Up()59 m.Up()
48 got, ok := m.Selected()60 got, ok := m.Selected()
@@ -66,6 +78,205 @@ func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) {
66 require.Equal(t, Target{Session: "s1", TabIndex: 1}, got, "up skips second session header")78 require.Equal(t, Target{Session: "s1", TabIndex: 1}, got, "up skips second session header")
67 }79 }
68 80
81+func TestModelConstructsTargetsForSelectionModes(t *testing.T) {
82+ incarnationOne := domain.IncarnationID{1}
83+ incarnationTwo := domain.IncarnationID{2}
84+ sessions := []SessionView{
85+ {ID: "s1", Name: "one", TargetName: "one", Incarnation: incarnationOne, Tabs: []TabEntry{{TabID: "t1", Name: "shell"}, {TabID: "t2", Name: "logs"}}, Active: 1},
86+ {ID: "s2", Name: "two", TargetName: "two", Incarnation: incarnationTwo, Tabs: []TabEntry{{TabID: "t3", Name: "api"}}, Active: 0},
87+ }
88+ tests := []struct {
89+ name string
90+ config SelectionConfig
91+ want Target
92+ }{
93+ {
94+ name: "navigation selects tab row",
95+ config: SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "s1", TabID: "t2"}},
96+ want: Target{Session: "s1", Incarnation: incarnationOne, TabID: "t2", TabIndex: 1},
97+ },
98+ {
99+ name: "move pane selects eligible tab row",
100+ config: SelectionConfig{Mode: SelectMovePaneTab, Current: SourceFilter{Session: "s1", TabID: "t1"}, Source: SourceFilter{Session: "s1", TabID: "t2"}},
101+ want: Target{Session: "s1", Incarnation: incarnationOne, Name: "one", TabID: "t1", TabIndex: 0},
102+ },
103+ {
104+ name: "move tab selects destination session header",
105+ config: SelectionConfig{Mode: SelectMoveTabSession, Current: SourceFilter{Session: "s2"}, Source: SourceFilter{Session: "s1"}},
106+ want: Target{Session: "s2", Incarnation: incarnationTwo, Name: "two", TabIndex: -1},
107+ },
108+ }
109+ for _, tt := range tests {
110+ t.Run(tt.name, func(t *testing.T) {
111+ model := New(sessions, tt.config)
112+
113+ got, ok := model.Selected()
114+ require.True(t, ok)
115+ require.Equal(t, tt.want, got)
116+ })
117+ }
118+}
119+
120+func TestModelMoveModesApplyExactDestinationFiltering(t *testing.T) {
121+ incarnation := domain.IncarnationID{1}
122+ sessions := []SessionView{
123+ {ID: "source", Incarnation: incarnation, Name: "source", Tabs: []TabEntry{{TabID: "source-tab", Name: "source"}, {TabID: "other-tab", Name: "other"}}},
124+ {ID: "only-source", Incarnation: incarnation, Name: "only-source", Tabs: []TabEntry{{TabID: "only-tab", Name: "only"}}},
125+ {ID: "destination", Incarnation: incarnation, Name: "destination", Tabs: []TabEntry{{TabID: "destination-tab", Name: "destination"}}},
126+ {ID: "empty", Incarnation: incarnation, Name: "empty"},
127+ {ID: "stopped", Incarnation: incarnation, Name: "stopped", Stopped: true, Tabs: []TabEntry{{TabID: "stopped-tab", Name: "stopped"}}},
128+ }
129+ type rowIdentity struct {
130+ kind rowKind
131+ session domain.SessionID
132+ tab domain.TabStableID
133+ }
134+ identities := func(model *Model) []rowIdentity {
135+ got := make([]rowIdentity, 0, len(model.rows))
136+ for _, pickerRow := range model.rows {
137+ got = append(got, rowIdentity{kind: pickerRow.kind, session: pickerRow.session, tab: pickerRow.tabID})
138+ }
139+ return got
140+ }
141+
142+ paneModel := New(sessions, SelectionConfig{Mode: SelectMovePaneTab, Source: SourceFilter{
143+ Session: "only-source", Incarnation: incarnation, TabID: "only-tab",
144+ }})
145+ require.Equal(t, []rowIdentity{
146+ {kind: rowSession, session: "source"},
147+ {kind: rowTab, session: "source", tab: "source-tab"},
148+ {kind: rowTab, session: "source", tab: "other-tab"},
149+ {kind: rowSession, session: "destination"},
150+ {kind: rowTab, session: "destination", tab: "destination-tab"},
151+ }, identities(paneModel))
152+
153+ tabModel := New(sessions, SelectionConfig{Mode: SelectMoveTabSession, Source: SourceFilter{
154+ Session: "source", Incarnation: incarnation,
155+ }})
156+ require.Equal(t, []rowIdentity{
157+ {kind: rowSession, session: "only-source"},
158+ {kind: rowTab, session: "only-source", tab: "only-tab"},
159+ {kind: rowSession, session: "destination"},
160+ {kind: rowTab, session: "destination", tab: "destination-tab"},
161+ }, identities(tabModel))
162+
163+ replacementIncarnation := domain.IncarnationID{2}
164+ replacementModel := New([]SessionView{
165+ {ID: "same", Incarnation: incarnation, Tabs: []TabEntry{{TabID: "same-tab"}}},
166+ {ID: "same", Incarnation: replacementIncarnation, Tabs: []TabEntry{{TabID: "same-tab"}}},
167+ }, SelectionConfig{Mode: SelectMovePaneTab, Source: SourceFilter{
168+ Session: "same", Incarnation: incarnation, TabID: "same-tab",
169+ }})
170+ require.Len(t, replacementModel.rows, 2, "only the exact source lifecycle is filtered")
171+ require.Equal(t, replacementIncarnation, replacementModel.rows[0].incarnation)
172+ require.Equal(t, replacementIncarnation, replacementModel.rows[1].incarnation)
173+}
174+
175+func TestRowKindDefinesRenderingAndSelectability(t *testing.T) {
176+ tests := []struct {
177+ name string
178+ kind rowKind
179+ mode SelectionMode
180+ header bool
181+ selectable bool
182+ }{
183+ {name: "navigation session", kind: rowSession, mode: SelectNavigationTab, header: true},
184+ {name: "navigation tab", kind: rowTab, mode: SelectNavigationTab, selectable: true},
185+ {name: "move pane session", kind: rowSession, mode: SelectMovePaneTab, header: true},
186+ {name: "move pane tab", kind: rowTab, mode: SelectMovePaneTab, selectable: true},
187+ {name: "move tab session", kind: rowSession, mode: SelectMoveTabSession, header: true, selectable: true},
188+ {name: "move tab tab", kind: rowTab, mode: SelectMoveTabSession},
189+ }
190+ for _, tt := range tests {
191+ t.Run(tt.name, func(t *testing.T) {
192+ require.Equal(t, tt.header, tt.kind.rendersAsHeader())
193+ require.Equal(t, tt.selectable, tt.kind.selectable(tt.mode))
194+ })
195+ }
196+}
197+
198+func TestModelMovePaneFallsBackToFirstEligibleTab(t *testing.T) {
199+ model := New([]SessionView{
200+ {ID: "source", Name: "source", Active: 0, Tabs: []TabEntry{{TabID: "source-tab", Name: "source"}, {TabID: "sibling", Name: "sibling"}}},
201+ {ID: "destination", Name: "destination", Active: 0, Tabs: []TabEntry{{TabID: "destination-tab", Name: "destination"}}},
202+ }, SelectionConfig{Mode: SelectMovePaneTab, Source: SourceFilter{Session: "source", TabID: "source-tab"}})
203+
204+ got, ok := model.Selected()
205+ require.True(t, ok)
206+ require.Equal(t, domain.TabStableID("sibling"), got.TabID)
207+}
208+
209+func TestModelMoveNavigationSkipsRowsNotSelectableForMode(t *testing.T) {
210+ sessions := []SessionView{
211+ {ID: "s1", Name: "one", Tabs: []TabEntry{{TabID: "t1", Name: "one"}}},
212+ {ID: "s2", Name: "two", Tabs: []TabEntry{{TabID: "t2", Name: "two"}}},
213+ }
214+
215+ paneModel := New(sessions, SelectionConfig{Mode: SelectMovePaneTab, Current: SourceFilter{Session: "s1", TabID: "t1"}})
216+ paneModel.Down()
217+ got, ok := paneModel.Selected()
218+ require.True(t, ok)
219+ require.Equal(t, domain.TabStableID("t2"), got.TabID, "move-pane navigation skips the second session header")
220+
221+ tabModel := New(sessions, SelectionConfig{Mode: SelectMoveTabSession, Current: SourceFilter{Session: "s1"}})
222+ tabModel.Down()
223+ got, ok = tabModel.Selected()
224+ require.True(t, ok)
225+ require.Equal(t, domain.SessionID("s2"), got.Session, "move-tab navigation skips tab rows")
226+}
227+
228+func TestModelSelectedKeepsImmutableLifecycleAndStableTabAcrossRefresh(t *testing.T) {
229+ createdAt := int64(41)
230+ views := []SessionView{{
231+ ID: "same-id", Name: "same-name", TargetName: "same-name", Incarnation: domain.IncarnationID{1}, ExpectedCreatedAt: &createdAt,
232+ Tabs: []TabEntry{{TabID: "first", Name: "first"}, {TabID: "stable", Name: "stable"}}, Active: 1,
233+ }}
234+ model := New(views, SelectionConfig{Mode: SelectMovePaneTab, Current: SourceFilter{Session: "same-id", TabID: "stable"}})
235+
236+ views[0].Incarnation = domain.IncarnationID{9}
237+ views[0].TargetName = "replacement"
238+ views[0].Tabs[1].TabID = "replacement-tab"
239+ createdAt = 99
240+ got, ok := model.Selected()
241+ require.True(t, ok)
242+ require.Equal(t, Target{
243+ Session: "same-id", Incarnation: domain.IncarnationID{1}, Name: "same-name", TabID: "stable", TabIndex: 1,
244+ ExpectedCreatedAt: int64Pointer(41),
245+ }, got, "model target remains bound to the lifecycle captured during construction")
246+
247+ refreshed := New([]SessionView{{
248+ ID: "same-id", Name: "same-name", TargetName: "same-name", Incarnation: domain.IncarnationID{1},
249+ Tabs: []TabEntry{{TabID: "stable", Name: "stable"}, {TabID: "first", Name: "first"}}, Active: 1,
250+ }}, SelectionConfig{Mode: SelectMovePaneTab, Current: SourceFilter{Session: got.Session, TabID: got.TabID}})
251+ got, ok = refreshed.Selected()
252+ require.True(t, ok)
253+ require.Equal(t, domain.TabStableID("stable"), got.TabID)
254+ require.Equal(t, 0, got.TabIndex, "mutable index follows the stable tab after refresh")
255+}
256+
257+func TestModelClonePreservesModeAndImmutableTarget(t *testing.T) {
258+ createdAt := int64(7)
259+ model := New([]SessionView{{
260+ ID: "s", Name: "session", TargetName: "session", Incarnation: domain.IncarnationID{3}, ExpectedCreatedAt: &createdAt,
261+ Tabs: []TabEntry{{TabID: "tab", Name: "tab"}},
262+ }}, SelectionConfig{Mode: SelectMovePaneTab})
263+ clone := model.Clone()
264+
265+ require.Equal(t, SelectMovePaneTab, clone.mode)
266+ got, ok := clone.Selected()
267+ require.True(t, ok)
268+ *got.ExpectedCreatedAt = 100
269+ gotAgain, ok := clone.Selected()
270+ require.True(t, ok)
271+ require.Equal(t, int64(7), *gotAgain.ExpectedCreatedAt, "returned targets cannot mutate the cloned model locator")
272+ require.Equal(t, domain.TabStableID("tab"), gotAgain.TabID)
273+ require.Equal(t, domain.IncarnationID{3}, gotAgain.Incarnation)
274+}
275+
276+func int64Pointer(value int64) *int64 {
277+ return new(value)
278+}
279+
69 func TestChooseLayoutResponsiveBoundaries(t *testing.T) {280 func TestChooseLayoutResponsiveBoundaries(t *testing.T) {
70 tests := []struct {281 tests := []struct {
71 name string282 name string
@@ -113,7 +324,7 @@ func TestChooseLayoutResponsiveBoundaries(t *testing.T) {
113 }324 }
114 325
115 func TestRenderDrawsCustomOrientedSeparators(t *testing.T) {326 func TestRenderDrawsCustomOrientedSeparators(t *testing.T) {
116- m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0)327+ m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
117 separator := renderer.Style{Foreground: 8, Attrs: renderer.AttrDim}328 separator := renderer.Style{Foreground: 8, Attrs: renderer.AttrDim}
118 styles := RenderStyles{Separator: separator}329 styles := RenderStyles{Separator: separator}
119 tests := []struct {330 tests := []struct {
@@ -140,7 +351,7 @@ func TestRenderDrawsCustomOrientedSeparators(t *testing.T) {
140 }351 }
141 352
142 func TestRenderListOnlyOmitsSeparatorAndPreview(t *testing.T) {353 func TestRenderListOnlyOmitsSeparatorAndPreview(t *testing.T) {
143- m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0)354+ m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
144 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}})355 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}})
145 356
146 require.Equal(t, ' ', frame.At(22, 10).Rune)357 require.Equal(t, ' ', frame.At(22, 10).Rune)
@@ -148,7 +359,7 @@ func TestRenderListOnlyOmitsSeparatorAndPreview(t *testing.T) {
148 }359 }
149 360
150 func TestRenderPreviewAnchorsOversizedSourceToFinalRows(t *testing.T) {361 func TestRenderPreviewAnchorsOversizedSourceToFinalRows(t *testing.T) {
151- m := New(nil, "", 0)362+ m := New(nil, SelectionConfig{Mode: SelectNavigationTab})
152 preview := Preview{Width: 24, Height: 9, Rows: previewRows(24, "abcdefghi")}363 preview := Preview{Width: 24, Height: 9, Rows: previewRows(24, "abcdefghi")}
153 364
154 frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview)365 frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview)
@@ -157,7 +368,7 @@ func TestRenderPreviewAnchorsOversizedSourceToFinalRows(t *testing.T) {
157 }368 }
158 369
159 func TestRenderPreviewBottomPlacesShortSource(t *testing.T) {370 func TestRenderPreviewBottomPlacesShortSource(t *testing.T) {
160- m := New(nil, "", 0)371+ m := New(nil, SelectionConfig{Mode: SelectNavigationTab})
161 preview := Preview{Width: 24, Height: 1, Rows: previewRows(24, "z")}372 preview := Preview{Width: 24, Height: 1, Rows: previewRows(24, "z")}
162 373
163 frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview)374 frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview)
@@ -166,7 +377,7 @@ func TestRenderPreviewBottomPlacesShortSource(t *testing.T) {
166 }377 }
167 378
168 func TestRenderAttentionMarkerSmokeWithResponsiveLayout(t *testing.T) {379 func TestRenderAttentionMarkerSmokeWithResponsiveLayout(t *testing.T) {
169- m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab", Attention: true}}, Active: 0}}, "s", 0)380+ m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab", Attention: true}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
170 frame := m.Render(domain.Size{Cols: 69, Rows: 4}, Preview{})381 frame := m.Render(domain.Size{Cols: 69, Rows: 4}, Preview{})
171 382
172 require.Equal(t, rune(ui.AttentionGlyph), frame.At(6, 1).Rune)383 require.Equal(t, rune(ui.AttentionGlyph), frame.At(6, 1).Rune)
@@ -186,17 +397,17 @@ func previewRows(width int, labels string) [][]renderer.Cell {
186 397
187 func TestSelectedMapping(t *testing.T) {398 func TestSelectedMapping(t *testing.T) {
188 m := New([]SessionView{399 m := New([]SessionView{
189- {ID: "alpha", Name: "alpha", Tabs: []TabEntry{{Name: "one"}}, Active: 0},400+ {ID: "alpha", Name: "alpha", Tabs: []TabEntry{{TabID: "one", Name: "one"}}, Active: 0},
190- {ID: "beta", Name: "beta", Tabs: []TabEntry{{Name: "two"}, {Name: "three"}}, Active: 0},401+ {ID: "beta", Name: "beta", Tabs: []TabEntry{{TabID: "two", Name: "two"}, {TabID: "three", Name: "three"}}, Active: 0},
191- }, "beta", 1)402+ }, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "beta", TabID: "three"}})
192 403
193 got, ok := m.Selected()404 got, ok := m.Selected()
194 require.True(t, ok)405 require.True(t, ok)
195- require.Equal(t, Target{Session: "beta", TabIndex: 1}, got)406+ require.Equal(t, Target{Session: "beta", TabID: "three", TabIndex: 1}, got)
196 }407 }
197 408
198 func TestStoppedSessionSelectableAndRendered(t *testing.T) {409 func TestStoppedSessionSelectableAndRendered(t *testing.T) {
199- m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, "", 0)410+ m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, SelectionConfig{Mode: SelectNavigationTab})
200 got, ok := m.Selected()411 got, ok := m.Selected()
201 require.True(t, ok)412 require.True(t, ok)
202 require.Equal(t, Target{Session: "stopped:work", Name: "work", TabIndex: 0, Stopped: true}, got)413 require.Equal(t, Target{Session: "stopped:work", Name: "work", TabIndex: 0, Stopped: true}, got)
@@ -206,7 +417,7 @@ func TestStoppedSessionSelectableAndRendered(t *testing.T) {
206 }417 }
207 418
208 func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {419 func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
209- m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0)420+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
210 preview := Preview{421 preview := Preview{
211 Width: 25,422 Width: 25,
212 Height: 1,423 Height: 1,
@@ -226,7 +437,7 @@ func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
226 }437 }
227 438
228 func TestRenderListScrollsSelectionIntoView(t *testing.T) {439 func TestRenderListScrollsSelectionIntoView(t *testing.T) {
229- m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a"}, {Name: "b"}, {Name: "c"}, {Name: "d"}, {Name: "e"}, {Name: "f"}}, Active: 0}}, "s1", 0)440+ 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})
230 for range 5 {441 for range 5 {
231 m.Down()442 m.Down()
232 }443 }
@@ -238,7 +449,7 @@ func TestRenderListScrollsSelectionIntoView(t *testing.T) {
238 }449 }
239 450
240 func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) {451 func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) {
241- m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a-really-long-focused-pane-tab-label"}}, Active: 0}}, "s1", 0)452+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a-really-long-focused-pane-tab-label"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
242 453
243 frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{})454 frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{})
244 455
@@ -249,7 +460,7 @@ func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) {
249 }460 }
250 461
251 func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) {462 func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) {
252- m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0)463+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
253 preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}464 preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}
254 465
255 frame := m.Render(domain.Size{Cols: 23, Rows: 11}, preview)466 frame := m.Render(domain.Size{Cols: 23, Rows: 11}, preview)
@@ -273,7 +484,7 @@ func TestRenderListDrawsNameAndDetailSegmentsWithDistinctStyles(t *testing.T) {
273 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{484 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{
274 {Name: "alpha", Detail: " (running)"},485 {Name: "alpha", Detail: " (running)"},
275 {Name: "beta", Detail: " (idle)", Attention: true},486 {Name: "beta", Detail: " (idle)", Attention: true},
276- }, Active: 0}}, "s1", 0)487+ }, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
277 488
278 frame := m.Render(domain.Size{Cols: 30, Rows: 5}, Preview{}, styles)489 frame := m.Render(domain.Size{Cols: 30, Rows: 5}, Preview{}, styles)
279 490
@@ -303,7 +514,7 @@ func TestRenderListDrawsNameAndDetailSegmentsWithDistinctStyles(t *testing.T) {
303 func TestRenderListTruncatesDetailBeforeName(t *testing.T) {514 func TestRenderListTruncatesDetailBeforeName(t *testing.T) {
304 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{515 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{
305 {Name: "short-name", Detail: " (a very long detail text)"},516 {Name: "short-name", Detail: " (a very long detail text)"},
306- }, Active: 0}}, "s1", 0)517+ }, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
307 518
308 frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{})519 frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{})
309 layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5})520 layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5})
@@ -320,7 +531,7 @@ func TestRenderListTruncatesDetailBeforeName(t *testing.T) {
320 func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) {531 func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) {
321 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{532 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{
322 {Name: "a-really-long-focused-pane-tab-label", Detail: " (detail)"},533 {Name: "a-really-long-focused-pane-tab-label", Detail: " (detail)"},
323- }, Active: 0}}, "s1", 0)534+ }, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
324 535
325 frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{})536 frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{})
326 layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5})537 layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5})
@@ -335,7 +546,7 @@ func cell(r rune) renderer.Cell {
335 }546 }
336 547
337 func TestRenderStylesFillBackgroundRowsAndSelection(t *testing.T) {548 func TestRenderStylesFillBackgroundRowsAndSelection(t *testing.T) {
338- m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0)549+ m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
339 background := renderer.Style{Foreground: 1, Background: 2}550 background := renderer.Style{Foreground: 1, Background: 2}
340 base := renderer.Style{Foreground: 3, Background: 4}551 base := renderer.Style{Foreground: 3, Background: 4}
341 selection := renderer.Style{Foreground: 5, Background: 6}552 selection := renderer.Style{Foreground: 5, Background: 6}
@@ -383,7 +594,7 @@ func TestPickerRowsKeepTerminalBackgroundAcrossAccentFallbacks(t *testing.T) {
383 model := New([]SessionView{594 model := New([]SessionView{
384 {ID: "selected", Name: "selected", Tabs: []TabEntry{{Name: "one"}}, Active: 0},595 {ID: "selected", Name: "selected", Tabs: []TabEntry{{Name: "one"}}, Active: 0},
385 {ID: "inactive", Name: "inactive", Tabs: []TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0},596 {ID: "inactive", Name: "inactive", Tabs: []TabEntry{{Name: "two", Detail: " (detail)"}}, Active: 0},
386- }, "selected", 0)597+ }, SelectionConfig{Mode: SelectNavigationTab})
387 for _, tt := range tests {598 for _, tt := range tests {
388 t.Run(tt.name, func(t *testing.T) {599 t.Run(tt.name, func(t *testing.T) {
389 styles := themeui.Resolve(tt.theme, tt.policy).Styles600 styles := themeui.Resolve(tt.theme, tt.policy).Styles

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