1package daemon
2
3import (
4 "crypto/sha256"
5 "encoding/base64"
6 "math"
7
8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/protocol"
10 "github.com/bnema/vev/internal/usecase/picker"
11)
12
13// This file owns picker destination eligibility and the structured lines the
14// serving daemon publishes. The daemon knows lifecycles, tab identities, and
15// remote activation; the presenting client only renders. Keys are opaque and
16// resolve back to picker.Target values inside the daemon.
17
18// pickerRemoteActivation is the picker action authorized by the current remote
19// catalog snapshot. It is presentation state only; the structured remote
20// target remains the exact route and is revalidated at commit time.
21type pickerRemoteActivation uint8
22
23const (
24 pickerRemoteUnavailable pickerRemoteActivation = iota
25 pickerRemoteAttach
26 pickerRemoteRestart
27)
28
29// pickerSessionView is the daemon-private projection of one session or remote
30// catalogue row used to compute destination eligibility.
31type pickerSessionView struct {
32 ID domain.SessionID
33 Section string
34 Incarnation domain.IncarnationID
35 Name string
36 TargetName string
37 Tabs []pickerTabEntry
38 Active int
39 Stopped bool
40 ExpectedCreatedAt *int64
41 RemoteKey *domain.RemoteSessionKey
42 HideRemoteOrigin bool
43 // RemoteTarget is the structured route/lifecycle identity for picker rows.
44 // It is never reconstructed from Name or a rendered label.
45 RemoteTarget *domain.RemoteSessionTarget
46 // RemoteHost marks remote host status rows that have no session key.
47 RemoteHost string
48 RemoteDetail string
49 RemoteReason string
50 RemoteActivation pickerRemoteActivation
51 // CannotAcceptMoves reports whether this session cannot receive a moved tab
52 // or pane. False for ordinary local (and stopped) sessions; true for
53 // restricted remote rows.
54 CannotAcceptMoves bool
55}
56
57// pickerTabEntry is one tab row; Name is drawn emphasized, Detail muted.
58type pickerTabEntry struct {
59 TabID domain.TabStableID // stable tab identity; independent of its current index
60 Name string // tab display name
61 RawName string // unformatted name used by exact remote selectors
62 Detail string // " (paneTitle)" or "", drawn muted
63 Attention bool // draw the attention marker right after Name, before Detail
64}
65
66// pickerSourceFilter names the row the attachment currently displays.
67type pickerSourceFilter struct {
68 Session domain.SessionID
69 Incarnation domain.IncarnationID
70 TabID domain.TabStableID
71 RemoteKey *domain.RemoteSessionKey
72}
73
74// pickerLine is one published line plus its source-private target. cursor
75// marks the row the attachment currently displays; defaultCursor marks the row
76// a fresh interaction starts on when nothing matches the current view.
77type pickerLine struct {
78 line protocol.PickerLine
79 target picker.Target
80 cursor bool
81 defaultCursor bool
82}
83
84// pickerLineSet is one published source: ordered lines, the opaque key map,
85// and the cursor hint.
86type pickerLineSet struct {
87 lines []protocol.PickerLine
88 keys map[string]picker.Target
89 cursor protocol.PickerCursor
90}
91
92// pickerLineSetFor projects one eligibility view set into structured lines in
93// view order (never canonicalized: order is semantically significant).
94func pickerLineSetFor(views []pickerSessionView, intent protocol.PickerIntent, source, current pickerSourceFilter) pickerLineSet {
95 set := pickerLineSet{keys: make(map[string]picker.Target)}
96 entries := make([]pickerLine, 0)
97 for _, view := range views {
98 if view.Section != "" {
99 entries = append(entries, pickerLine{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: view.Section, Dim: true}})
100 }
101 entries = append(entries, pickerSessionLines(view, intent, source, current)...)
102 }
103 for i, entry := range entries {
104 set.lines = append(set.lines, entry.line)
105 if entry.line.Key == "" {
106 continue
107 }
108 if _, dup := set.keys[entry.line.Key]; !dup {
109 set.keys[entry.line.Key] = entry.target
110 }
111 if entry.cursor {
112 set.cursor = protocol.PickerCursor{Key: entry.line.Key, Index: i}
113 }
114 }
115 if set.cursor.Key == "" {
116 // Prefer the active tab of a session over the first published row.
117 for i, entry := range entries {
118 if !entry.defaultCursor {
119 continue
120 }
121 set.cursor = protocol.PickerCursor{Key: entry.line.Key, Index: i}
122 break
123 }
124 }
125 if set.cursor.Key == "" && len(set.lines) > 0 {
126 // Last resort: the first row the source authorised as a cursor
127 // destination. A row kept for inspection or skipped by the source is
128 // never hinted.
129 for i, line := range set.lines {
130 if !line.Focusable {
131 continue
132 }
133 if target, ok := set.keys[line.Key]; ok && !target.Stopped {
134 set.cursor = protocol.PickerCursor{Key: line.Key, Index: i}
135 break
136 }
137 }
138 }
139 return set
140}
141
142// pickerRowKey derives the opaque key of one picker row. Local rows are keyed by
143// incarnation and name, which stays readable for the common case. A remote row
144// also carries its owning endpoint: the same machine can be registered under
145// more than one target (a pinned alias and one learned by attach), and two
146// endpoints may legitimately expose the same session lifecycle and name, so an
147// unqualified key would collide and the whole snapshot would be rejected. The
148// digest keeps the key unique per endpoint while staying inside the bounded key
149// length.
150func pickerRowKey(view pickerSessionView, name string) string {
151 if view.RemoteKey == nil {
152 return pickerClientKey(view.Incarnation, name)
153 }
154 digest := sha256.Sum256([]byte(view.RemoteKey.Host + "\x00" + string(view.Incarnation[:]) + "\x00" + name))
155 return "remote:" + base64.RawURLEncoding.EncodeToString(digest[:16])
156}
157
158// pickerSessionLines is the sole owner of intent-specific destination
159// eligibility. The daemon supplies canonical lifecycle/tab snapshots without
160// prefiltering.
161func pickerSessionLines(view pickerSessionView, intent protocol.PickerIntent, source, current pickerSourceFilter) []pickerLine {
162 stopped := view.Stopped
163 if view.RemoteTarget != nil {
164 stopped = view.RemoteTarget.Stopped
165 }
166 if intent != protocol.PickerIntentNavigation && stopped {
167 return nil
168 }
169 if intent == protocol.PickerIntentMoveTab && pickerSourceMatchesSession(source, view) {
170 return nil
171 }
172 if intent == protocol.PickerIntentMoveTab && len(view.Tabs) == 0 && !view.CannotAcceptMoves {
173 return nil
174 }
175
176 targetName := view.TargetName
177 if intent == protocol.PickerIntentNavigation && !stopped && view.ExpectedCreatedAt == nil {
178 targetName = ""
179 } else if targetName == "" {
180 targetName = view.Name
181 }
182 expectedCreatedAt, hasExpectedCreatedAt := int64Value(view.ExpectedCreatedAt)
183 common := picker.Target{
184 Session: view.ID, Incarnation: view.Incarnation, Name: targetName,
185 RemoteKey: view.RemoteKey, RemoteTarget: view.RemoteTarget, RemoteHost: view.RemoteHost,
186 UnavailableReason: view.RemoteReason, TabIndex: -1, Stopped: stopped,
187 ExpectedCreatedAt: optionalInt64(expectedCreatedAt, hasExpectedCreatedAt),
188 }
189 headerLine := pickerLine{
190 line: protocol.PickerLine{
191 Key: pickerRowKey(view, common.Name), Kind: pickerLineKindForView(view),
192 Label: view.Name, Detail: pickerStatusDetailFor(view, stopped), Stopped: stopped,
193 Status: pickerStatusFor(view, stopped), StatusDetail: view.RemoteDetail, Ephemeral: pickerEphemeral(view),
194 },
195 target: common,
196 }
197 if view.RemoteKey != nil {
198 headerLine.line.Label = view.RemoteKey.Name
199 if !view.HideRemoteOrigin {
200 display := view.RemoteKey.Display()
201 headerLine.line.Detail = display[len(view.RemoteKey.Name):]
202 }
203 }
204 header := headerLine
205 headerSelectable, headerFocusable, headerDim := pickerHeaderEligibility(view, intent, stopped)
206 header.line.Actions = pickerActionsFor(view, intent, headerSelectable)
207 header.line.Focusable = headerFocusable
208 header.line.Dim = headerDim
209 if headerFocusable && pickerSelectionMatches(header, current, intent) {
210 header.cursor = true
211 }
212 if !headerFocusable {
213 header.line.Actions = 0
214 header.target = picker.Target{}
215 }
216
217 lines := []pickerLine{header}
218 for i, tab := range view.Tabs {
219 if intent == protocol.PickerIntentMovePane && pickerSourceMatchesTab(source, view, tab) {
220 continue
221 }
222 tabTarget := common
223 tabTarget.TabID, tabTarget.TabIndex = tab.TabID, i
224 // Each tab row carries its own structured remote target: a stopped row
225 // restores the tab it names, a live row binds its stable tab ID.
226 if resolved, ok := pickerTabRemoteTarget(view, tab, i); ok {
227 tabTarget.RemoteTarget = &resolved
228 }
229 tabLine := pickerLine{
230 line: protocol.PickerLine{
231 Key: pickerRowKey(view, common.Name+"#"+string(tab.TabID)),
232 Kind: protocol.PickerLineTab,
233 Label: tab.Name, Detail: tab.Detail, Attention: tab.Attention,
234 Stopped: stopped, StatusDetail: view.RemoteDetail, Ephemeral: pickerEphemeral(view),
235 },
236 target: tabTarget,
237 }
238 selectable, focusable, dim := pickerTabEligibility(view, intent, tab, i)
239 tabLine.line.Actions = pickerActionsFor(view, intent, selectable)
240 tabLine.line.Focusable = focusable
241 tabLine.line.Dim = dim
242 if !focusable {
243 tabLine.line.Actions = 0
244 tabLine.target = picker.Target{}
245 }
246 if focusable && pickerSelectionMatches(tabLine, current, intent) {
247 tabLine.cursor = true
248 }
249 // A fresh navigation starts on the session's active tab, exactly like
250 // the daemon-owned model did.
251 if focusable && intent == protocol.PickerIntentNavigation && i == normalizedActiveTab(view) {
252 tabLine.defaultCursor = true
253 }
254 lines = append(lines, tabLine)
255 }
256 if intent == protocol.PickerIntentMovePane && len(lines) == 1 && !view.CannotAcceptMoves {
257 return nil
258 }
259 return lines
260}
261
262func pickerHeaderEligibility(view pickerSessionView, intent protocol.PickerIntent, stopped bool) (selectable, focusable, dim bool) {
263 if intent == protocol.PickerIntentMoveTab {
264 selectable, focusable = true, true
265 } else if view.ID != "" && pickerViewHasTarget(view) {
266 selectable, focusable = false, false
267 }
268 if intent == protocol.PickerIntentNavigation && stopped && len(view.Tabs) == 0 && pickerViewHasTarget(view) {
269 selectable, focusable = true, true
270 }
271 if pickerViewIsRemote(view) {
272 if view.RemoteTarget != nil {
273 selectable = intent == protocol.PickerIntentNavigation && stopped && len(view.Tabs) == 0 && pickerRemoteActivatable(view) && view.RemoteTarget.Validate() == nil
274 focusable = intent == protocol.PickerIntentNavigation && len(view.Tabs) == 0
275 } else {
276 selectable = false
277 focusable = intent == protocol.PickerIntentNavigation && view.RemoteHost != ""
278 }
279 dim = view.RemoteActivation == pickerRemoteUnavailable
280 }
281 if intent != protocol.PickerIntentNavigation && view.CannotAcceptMoves {
282 selectable, focusable, dim = false, false, true
283 }
284 return selectable, focusable, dim
285}
286
287func pickerTabEligibility(view pickerSessionView, intent protocol.PickerIntent, tab pickerTabEntry, index int) (selectable, focusable, dim bool) {
288 selectable, focusable = intent != protocol.PickerIntentMoveTab, intent != protocol.PickerIntentMoveTab
289 if pickerViewIsRemote(view) {
290 if view.RemoteTarget == nil {
291 focusable, selectable = false, false
292 } else {
293 resolved, resolvable := pickerTabRemoteTarget(view, tab, index)
294 focusable = true
295 selectable = resolvable && resolved.Validate() == nil && intent == protocol.PickerIntentNavigation && pickerRemoteActivatable(view)
296 }
297 dim = view.RemoteActivation == pickerRemoteUnavailable
298 }
299 if intent != protocol.PickerIntentNavigation && view.CannotAcceptMoves {
300 selectable, focusable, dim = false, false, true
301 }
302 return selectable, focusable, dim
303}
304
305// pickerTabRemoteTarget resolves the per-tab copy of a remote session's exact
306// route. A stopped session targets the tab the row names (stable ID when known,
307// otherwise its ordinal and raw name); a live session binds the tab's stable
308// ID. The second result is false when no exact target can be expressed.
309func pickerTabRemoteTarget(view pickerSessionView, tab pickerTabEntry, index int) (domain.RemoteSessionTarget, bool) {
310 if view.RemoteTarget == nil {
311 return domain.RemoteSessionTarget{}, false
312 }
313 target := *view.RemoteTarget
314 if target.Stopped {
315 target.LiveTabID = ""
316 if tab.TabID != "" {
317 target.StoppedTab = domain.NewStableTabSelector(tab.TabID)
318 return target, true
319 }
320 if target.StoppedTab == (domain.TabSelector{}) {
321 return target, true
322 }
323 selector, ok := remoteStoppedOrdinalSelector(index, tab.RawName, len(view.Tabs))
324 if !ok {
325 return domain.RemoteSessionTarget{}, false
326 }
327 target.StoppedTab = selector
328 return target, true
329 }
330 target.StoppedTab = domain.TabSelector{}
331 target.LiveTabID = tab.TabID
332 return target, true
333}
334
335// pickerActionsFor maps eligibility to the actions the source authorises. The
336// daemon revalidates key and lifecycle at commit, so these bits are a
337// presentation contract, never authority.
338// normalizedActiveTab clamps a session's active tab index into its tab range.
339func normalizedActiveTab(view pickerSessionView) int {
340 if view.Active < 0 || view.Active >= len(view.Tabs) {
341 return 0
342 }
343 return view.Active
344}
345
346func pickerActionsFor(view pickerSessionView, intent protocol.PickerIntent, selectable bool) protocol.PickerLineActions {
347 if !selectable {
348 return 0
349 }
350 if intent != protocol.PickerIntentNavigation {
351 return protocol.PickerCanMove
352 }
353 actions := protocol.PickerCanNavigate
354 if !pickerViewIsRemote(view) && pickerViewHasTarget(view) {
355 actions |= protocol.PickerCanKill
356 }
357 return actions
358}
359
360func pickerViewHasTarget(view pickerSessionView) bool {
361 return view.ID != "" && view.Name != ""
362}
363
364// pickerLineKindForView names one published header row's shape. A remote host
365// status row carries no session key and no structured route: it is published
366// for inspection, so it is a host row rather than a session row.
367func pickerLineKindForView(view pickerSessionView) protocol.PickerLineKind {
368 if pickerViewHostStatus(view) {
369 return protocol.PickerLineHost
370 }
371 return protocol.PickerLineSession
372}
373
374// pickerViewHostStatus reports a catalogue host line: an endpoint with no
375// session identity behind it.
376func pickerViewHostStatus(view pickerSessionView) bool {
377 return view.RemoteHost != "" && view.RemoteKey == nil && view.RemoteTarget == nil
378}
379
380func pickerViewIsRemote(view pickerSessionView) bool {
381 return view.RemoteKey != nil || view.RemoteTarget != nil || view.RemoteHost != ""
382}
383
384func pickerEphemeral(view pickerSessionView) bool {
385 return view.RemoteKey != nil || view.RemoteTarget != nil || view.RemoteHost != ""
386}
387
388func pickerRemoteActivatable(view pickerSessionView) bool {
389 return view.RemoteActivation == pickerRemoteAttach || view.RemoteActivation == pickerRemoteRestart
390}
391
392func pickerStatusFor(view pickerSessionView, stopped bool) protocol.PickerLineStatus {
393 remote := pickerViewIsRemote(view)
394 if remote {
395 switch view.RemoteReason {
396 case domain.RemoteReasonNoDaemon:
397 return protocol.PickerLineStatusNoDaemon
398 case domain.RemoteReasonHostUnreachable:
399 return protocol.PickerLineStatusDown
400 case domain.RemoteReasonVersionMismatch:
401 return protocol.PickerLineStatusVersion
402 case domain.RemoteReasonRefreshing, domain.RemoteReasonCatalogStale:
403 return protocol.PickerLineStatusStale
404 case domain.RemoteReasonMalformed, domain.RemoteReasonSessionBroken, domain.RemoteReasonIdentityChanged, domain.RemoteReasonAuthFailure:
405 return protocol.PickerLineStatusError
406 }
407 }
408 if stopped {
409 return protocol.PickerLineStatusStopped
410 }
411 if !remote {
412 return protocol.PickerLineStatusNone
413 }
414 if view.RemoteActivation == pickerRemoteAttach {
415 return protocol.PickerLineStatusUp
416 }
417 return protocol.PickerLineStatusError
418}
419
420// pickerStatusDetailFor fills the muted detail of a header line. Statuses
421// already represented by a badge are not repeated as adjacent text.
422func pickerStatusDetailFor(view pickerSessionView, _ bool) string {
423 if view.RemoteReason != "" {
424 return view.RemoteReason
425 }
426 return ""
427}
428
429func pickerSelectionMatches(line pickerLine, current pickerSourceFilter, intent protocol.PickerIntent) bool {
430 target := line.target
431 if target.Session != current.Session || (current.Incarnation != (domain.IncarnationID{}) && target.Incarnation != current.Incarnation) {
432 return false
433 }
434 if target.RemoteKey != nil && line.line.Kind == protocol.PickerLineSession {
435 return line.line.Actions != 0 && (current.RemoteKey == nil || *target.RemoteKey == *current.RemoteKey)
436 }
437 if intent == protocol.PickerIntentMoveTab {
438 return line.line.Kind == protocol.PickerLineSession
439 }
440 // A stopped session with no retained tab metadata uses its selectable
441 // header as the default restart target and matches on exact session
442 // identity.
443 if current.TabID == "" && target.Stopped {
444 return line.line.Kind == protocol.PickerLineSession && line.line.Actions != 0
445 }
446 if line.line.Kind != protocol.PickerLineTab {
447 return false
448 }
449 return target.TabID == current.TabID
450}
451
452func pickerSourceMatchesSession(source pickerSourceFilter, view pickerSessionView) bool {
453 if source.Session != view.ID {
454 return false
455 }
456 return source.Incarnation == (domain.IncarnationID{}) || source.Incarnation == view.Incarnation
457}
458
459func pickerSourceMatchesTab(source pickerSourceFilter, view pickerSessionView, tab pickerTabEntry) bool {
460 return pickerSourceMatchesSession(source, view) && source.TabID == tab.TabID
461}
462
463func remoteStoppedOrdinalSelector(index int, rawName string, tabCount int) (domain.TabSelector, bool) {
464 if tabCount > math.MaxUint16 || index < 0 || index >= tabCount {
465 return domain.TabSelector{}, false
466 }
467 return domain.NewOrdinalTabSelector(uint16(index), rawName, uint16(tabCount)), true
468}
469
470func int64Value(value *int64) (int64, bool) {
471 if value == nil {
472 return 0, false
473 }
474 return *value, true
475}
476
477func optionalInt64(value int64, present bool) *int64 {
478 if !present {
479 return nil
480 }
481 copyValue := value
482 return ©Value
483}