1// Package daemon holds vev's server-side session multiplexer use case.
2package daemon
3
4import (
5 "github.com/bnema/vev/internal/domain"
6 "github.com/bnema/vev/internal/usecase/picker"
7)
8
9func (d *Daemon) movePickerSourceError(source moveSourceLocator) error {
10 if d == nil || source.Session.ID == "" || source.TabID == "" {
11 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
12 }
13 sess := d.sessionByID(source.Session.ID)
14 if sess == nil || sess.incarnation != source.Session.Incarnation {
15 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
16 }
17 sess.mu.Lock()
18 defer sess.mu.Unlock()
19 if source.AttachmentToken.ac != nil && !moveAttachmentTokenCurrentLocked(source.AttachmentToken, sess) {
20 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source attachment is no longer active.", errMovePaneInvalid)
21 }
22 if source.Attachment != nil && !attachmentRegisteredLocked(sess, source.Attachment) {
23 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source attachment is no longer active.", errMovePaneInvalid)
24 }
25 tb := findMoveTabLocked(sess, source.TabID)
26 if tb == nil {
27 return domain.UserWarn(domain.NoticeSessionUnavailable, "Tab no longer exists.", errMovePaneInvalid)
28 }
29 if source.PaneID == "" {
30 return nil
31 }
32 tb.mu.Lock()
33 defer tb.mu.Unlock()
34 if paneByStableIDLocked(tb, string(source.PaneID)) == nil {
35 return domain.UserWarn(domain.NoticeSessionUnavailable, "Pane no longer exists.", errMovePaneInvalid)
36 }
37 return nil
38}
39
40type pickerIntent uint8
41
42const (
43 pickerNavigate pickerIntent = iota
44 pickerMovePane
45 pickerMoveTab
46)
47
48type moveSessionLocator struct {
49 ID domain.SessionID
50 Incarnation domain.IncarnationID
51 Name string
52}
53
54type moveSourceLocator struct {
55 Session moveSessionLocator
56 TabID domain.TabStableID
57 PaneID domain.PaneStableID
58 Attachment *attachedClient
59 AttachmentToken attachmentConnectionToken
60}
61
62// enterPickerForIntent returns errNoMoveDestination only for move intents.
63func (d *Daemon) enterPickerForIntent(sess *session, ac *attachedClient, intent pickerIntent, source moveSourceLocator) error {
64 if intent != pickerNavigate && !sess.capabilities().yieldsMoves() {
65 return errSessionCannotYieldMoves
66 }
67 if source.Attachment != nil && source.AttachmentToken.ac == nil {
68 source.AttachmentToken = sess.attachmentToken(source.Attachment, source.Attachment.transport())
69 }
70 model := d.newPickerModel(sess, ac, intent, source, picker.SourceFilter{})
71 if intent != pickerNavigate {
72 if _, ok := model.Selected(); !ok {
73 return errNoMoveDestination
74 }
75 }
76
77 d.publishPicker(sess, ac, model, intent, source)
78 return nil
79}
80
81func pickerSelectionMode(intent pickerIntent) picker.SelectionMode {
82 switch intent {
83 case pickerMovePane:
84 return picker.SelectMovePaneTab
85 case pickerMoveTab:
86 return picker.SelectMoveTabSession
87 default:
88 return picker.SelectNavigationTab
89 }
90}
91
92func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourceLocator, target picker.Target) error {
93 destination := moveSessionLocator{ID: target.Session, Incarnation: target.Incarnation, Name: target.Name}
94 switch intent {
95 case pickerMovePane:
96 if target.TabID == "" {
97 return errMovePaneInvalid
98 }
99 return d.movePane(movePaneRequest{
100 Attachment: source.Attachment,
101 AttachmentToken: source.AttachmentToken,
102 Source: source.Session,
103 SourceTabID: source.TabID,
104 SourcePaneID: source.PaneID,
105 Destination: destination,
106 DestinationTabID: target.TabID,
107 })
108 case pickerMoveTab:
109 return d.moveTab(moveTabRequest{
110 Attachment: source.Attachment,
111 AttachmentToken: source.AttachmentToken,
112 Source: source.Session,
113 SourceTabID: source.TabID,
114 Destination: destination,
115 })
116 default:
117 return errMovePaneInvalid
118 }
119}
120
121func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*session, *tab) {
122 d.mu.Lock()
123 sess := d.sessions[target.Session]
124 if sess == nil {
125 d.mu.Unlock()
126 return nil, nil
127 }
128 sess.mu.Lock()
129 d.mu.Unlock()
130 defer sess.mu.Unlock()
131 if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt) {
132 return nil, nil
133 }
134 if intent == pickerMoveTab {
135 if len(sess.tabs) == 0 {
136 return nil, nil
137 }
138 return sess, sess.tabs[0]
139 }
140 if target.TabID != "" {
141 for _, tb := range sess.tabs {
142 if domain.TabStableID(tb.stableID) == target.TabID {
143 return sess, tb
144 }
145 }
146 return nil, nil
147 }
148 if target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) {
149 return nil, nil
150 }
151 return sess, sess.tabs[target.TabIndex]
152}
153
154type pickerRefreshOptions struct {
155 // preserveSelection keeps the currently selected target selected across the
156 // rebuild even for navigate intent (used by the sort toggle).
157 preserveSelection bool
158 // nearestRow, when >= 0, selects the row occupying this index after the
159 // rebuild (used after deletes). -1 disables it.
160 nearestRow int
161}
162
163func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions) {
164 sess := ac.currentSession()
165 if sess == nil {
166 return
167 }
168 rt := ac.overlays
169 rt.pickerMu.Lock()
170 if rt.picker == nil {
171 rt.pickerMu.Unlock()
172 return
173 }
174 intent, source := rt.pickerIntent, rt.pickerSource
175 observedModel, observedGeneration := rt.picker, rt.pickerGeneration
176 current := picker.SourceFilter{}
177 if intent != pickerNavigate || opts.preserveSelection {
178 cursor, _ := rt.picker.Cursor()
179 current = picker.SourceFilter{
180 Session: cursor.Session, Incarnation: cursor.Incarnation, TabID: cursor.TabID, RemoteKey: cursor.RemoteKey,
181 }
182 }
183 rt.pickerMu.Unlock()
184 model := d.newPickerModel(sess, ac, intent, source, current)
185 if rt.afterPickerRefreshBuild != nil {
186 rt.afterPickerRefreshBuild(model)
187 }
188 if intent != pickerNavigate {
189 if _, ok := model.Selected(); !ok {
190 d.closePickerIfCurrent(ac, observedModel, observedGeneration)
191 return
192 }
193 }
194 // Navigate-only: move intents keep the selection-or-close logic above
195 // authoritative, so a stale row index can never override it.
196 if intent == pickerNavigate && opts.nearestRow >= 0 {
197 model.SelectNearestRow(opts.nearestRow)
198 }
199 rt.pickerMu.Lock()
200 updated := rt.picker == observedModel && rt.pickerGeneration == observedGeneration && rt.pickerIntent == intent && rt.pickerSource == source
201 if updated {
202 rt.picker = model
203 rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))
204 }
205 rt.pickerMu.Unlock()
206 if updated {
207 d.registerPreviewForSelection(ac)
208 }
209}