1package daemon
2
3import (
4 "bytes"
5 "cmp"
6 "slices"
7
8 vt "github.com/bnema/vev-vt"
9 "github.com/bnema/vev/internal/domain"
10)
11
12// attachmentView is the mutable viewport owned by one attachment. Tab and
13// pane identity is stable across tab insertion, deletion, and reordering;
14// neither field is a session-local index or layout PaneID.
15type attachmentView struct {
16 tabID domain.TabStableID
17 paneID domain.PaneStableID
18 windowTop int
19 windowRows int
20 windowSet bool
21 bookmark vt.RowID
22 liveBottom bool
23 revision uint64
24}
25
26func (ac *attachedClient) viewSnapshot() attachmentView {
27 if ac == nil {
28 return attachmentView{}
29 }
30 ac.viewMu.Lock()
31 defer ac.viewMu.Unlock()
32 return ac.view
33}
34
35func (ac *attachedClient) publishViewLocked(view attachmentView) {
36 // Rebase while viewMu is held, before the new revision becomes visible.
37 // Output fences take the same lock, so no side effect can pair the new
38 // ViewRevision with the previous output epoch. Test/headless fixtures may
39 // leave output.attachment unset; use the stream-local lock for those.
40 if ac.output != nil && ac.view.revision != view.revision {
41 if ac.output.attachment == ac {
42 ac.output.rebaseLocked()
43 } else {
44 ac.output.stateMu.Lock()
45 ac.output.rebaseLocked()
46 ac.output.stateMu.Unlock()
47 }
48 }
49 ac.view = view
50}
51
52func (ac *attachedClient) publishView(view attachmentView) {
53 if ac == nil {
54 return
55 }
56 ac.viewMu.Lock()
57 defer ac.viewMu.Unlock()
58 ac.publishViewLocked(view)
59}
60
61func (ac *attachedClient) publishViewIfCurrent(before, view attachmentView) bool {
62 if ac == nil {
63 return false
64 }
65 ac.viewMu.Lock()
66 defer ac.viewMu.Unlock()
67 if ac.view != before {
68 return false
69 }
70 ac.publishViewLocked(view)
71 return true
72}
73
74// registerAttachmentLocked admits ac exactly once. Caller holds s.mu and must
75// have acquired dispatchMu first when the operation came from a client.
76func (s *session) registerAttachmentLocked(ac *attachedClient) bool {
77 if s == nil || !s.sessionCore.registerAttachmentLocked(ac) {
78 return false
79 }
80 view := ac.viewSnapshot()
81 if view.tabID == "" || view.paneID == "" {
82 view = s.repairAttachmentViewLocked(ac, view)
83 ac.publishView(view)
84 }
85 return true
86}
87
88func (c *sessionCore) registerAttachmentLocked(ac *attachedClient) bool {
89 if c == nil || ac == nil {
90 return false
91 }
92 if c.attachments == nil {
93 c.attachments = make(map[*attachedClient]struct{})
94 }
95 if _, exists := c.attachments[ac]; exists {
96 return false
97 }
98 if c.attachmentOrder == nil {
99 c.attachmentOrder = make(map[*attachedClient]uint64)
100 }
101 c.nextAttachmentID++
102 c.attachments[ac] = struct{}{}
103 c.attachmentOrder[ac] = c.nextAttachmentID
104 if c.geometry.claimLocked(c, ac, ac.geometrySnapshot()) == nil {
105 c.geometry.refreshLocked(c)
106 }
107 return true
108}
109
110func registerAttachmentSessionLocked(entry *session, ac *attachedClient) bool {
111 if entry == nil || entry.core() == nil {
112 return false
113 }
114 // Transition publication may already hold tab locks (move commit). Only
115 // publish collection membership here; target repair runs at the next view
116 // snapshot outside that critical section.
117 return entry.core().registerAttachmentLocked(ac)
118}
119
120func (s *session) registerAttachment(ac *attachedClient) bool {
121 if s == nil || ac == nil {
122 return false
123 }
124 s.dispatchMu.Lock()
125 defer s.dispatchMu.Unlock()
126 s.mu.Lock()
127 defer s.mu.Unlock()
128 return s.registerAttachmentLocked(ac)
129}
130
131// unregisterAttachmentLocked removes only ac and leaves every other
132// attachment's view intact. Caller holds s.mu.
133func (s *session) unregisterAttachmentLocked(ac *attachedClient) bool {
134 if s == nil {
135 return false
136 }
137 return s.sessionCore.unregisterAttachmentLocked(ac)
138}
139
140func (c *sessionCore) unregisterAttachmentLocked(ac *attachedClient) bool {
141 if c == nil || ac == nil || c.attachments == nil {
142 return false
143 }
144 if _, exists := c.attachments[ac]; !exists {
145 return false
146 }
147 delete(c.attachments, ac)
148 delete(c.attachmentOrder, ac)
149 c.geometry.removeAttachmentLocked(c, ac)
150 return true
151}
152
153func unregisterAttachmentSessionLocked(entry *session, ac *attachedClient) bool {
154 if entry == nil || entry.core() == nil {
155 return false
156 }
157 return entry.core().unregisterAttachmentLocked(ac)
158}
159
160func (s *session) unregisterAttachment(ac *attachedClient) bool {
161 if s == nil || ac == nil {
162 return false
163 }
164 s.dispatchMu.Lock()
165 defer s.dispatchMu.Unlock()
166 s.mu.Lock()
167 defer s.mu.Unlock()
168 return s.unregisterAttachmentLocked(ac)
169}
170
171func (s *session) snapshotAttachmentsLocked() []*attachedClient {
172 if s == nil || len(s.attachments) == 0 {
173 return nil
174 }
175 out := make([]*attachedClient, 0, len(s.attachments))
176 for ac := range s.attachments {
177 out = append(out, ac)
178 }
179 slices.SortStableFunc(out, func(a, b *attachedClient) int {
180 // ClientID is the stable wire identity, so concurrent registration order
181 // cannot affect snapshots or invalidation order.
182 if id := bytes.Compare(a.clientID[:], b.clientID[:]); id != 0 {
183 return id
184 }
185 return cmp.Compare(s.attachmentOrder[a], s.attachmentOrder[b])
186 })
187 return out
188}
189
190func attachmentRegisteredLocked(entry *session, ac *attachedClient) bool {
191 if entry == nil || ac == nil || entry.core() == nil {
192 return false
193 }
194 _, ok := entry.core().attachments[ac]
195 return ok
196}
197
198func attachmentRegistered(entry *session, ac *attachedClient) bool {
199 if entry == nil || ac == nil || entry.core() == nil {
200 return false
201 }
202 core := entry.core()
203 core.mu.Lock()
204 defer core.mu.Unlock()
205 return attachmentRegisteredLocked(entry, ac)
206}
207
208func (s *session) attachmentRegistered(ac *attachedClient) bool {
209 return attachmentRegistered(s, ac)
210}
211
212func (s *session) attachmentRegisteredLocked(ac *attachedClient) bool {
213 return attachmentRegisteredLocked(s, ac)
214}
215
216func (s *session) attachmentViewsTabLocked(tb *tab) bool {
217 if s == nil || tb == nil {
218 return false
219 }
220 for ac := range s.attachments {
221 if ac.viewSnapshot().tabID == domain.TabStableID(tb.stableID) {
222 return true
223 }
224 }
225 return false
226}
227
228func (s *session) snapshotAttachments() []*attachedClient {
229 if s == nil {
230 return nil
231 }
232 s.mu.Lock()
233 defer s.mu.Unlock()
234 return s.snapshotAttachmentsLocked()
235}
236
237// repairAttachmentViewLocked validates stable targets and chooses the nearest
238// surviving target when a tab or pane was removed. Caller holds s.mu.
239func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachmentView) attachmentView {
240 _ = ac
241 if s == nil {
242 return attachmentView{}
243 }
244 var targetTab *tab
245 for _, tb := range s.tabs {
246 if tb != nil && domain.TabStableID(tb.stableID) == view.tabID {
247 targetTab = tb
248 break
249 }
250 }
251 tabRepaired := targetTab == nil
252 if targetTab == nil && len(s.tabs) != 0 {
253 targetTab = s.tabs[0]
254 }
255 if targetTab == nil {
256 view.tabID = ""
257 view.paneID = ""
258 view.windowTop = 0
259 view.windowRows = 0
260 view.windowSet = false
261 view.bookmark = 0
262 view.liveBottom = true
263 return view
264 }
265 view.tabID = domain.TabStableID(targetTab.stableID)
266
267 targetPane := (*pane)(nil)
268 targetTab.mu.Lock()
269 for _, candidate := range targetTab.panes {
270 if candidate != nil && domain.PaneStableID(candidate.stableID) == view.paneID {
271 targetPane = candidate
272 break
273 }
274 }
275 paneRepaired := targetPane == nil
276 if targetPane == nil {
277 targetPane = targetTab.focusedPane()
278 }
279 if targetPane != nil {
280 view.paneID = domain.PaneStableID(targetPane.stableID)
281 } else {
282 view.paneID = ""
283 }
284 targetTab.mu.Unlock()
285 if view.windowRows <= 0 {
286 targetTab.mu.Lock()
287 view.windowRows = targetTab.size.Rows
288 targetTab.mu.Unlock()
289 }
290 if view.windowTop < 0 {
291 view.windowTop = 0
292 }
293 if tabRepaired || paneRepaired || targetPane == nil {
294 view.bookmark = 0
295 view.liveBottom = true
296 }
297 return view
298}
299
300// repairAttachmentView repairs ac and increments its revision only when the
301// stable target changed. It is deliberately separate from registration so
302// layout mutation code can validate every surviving attachment in one ordered
303// pass.
304func (s *session) repairAttachmentView(ac *attachedClient) bool {
305 if s == nil || ac == nil {
306 return false
307 }
308 before := ac.viewSnapshot()
309 s.mu.Lock()
310 if _, ok := s.attachments[ac]; !ok {
311 s.mu.Unlock()
312 return false
313 }
314 after := s.repairAttachmentViewLocked(ac, before)
315 if before.tabID == after.tabID && before.paneID == after.paneID && before.windowTop == after.windowTop && before.windowRows == after.windowRows && before.windowSet == after.windowSet && before.bookmark == after.bookmark && before.liveBottom == after.liveBottom {
316 s.mu.Unlock()
317 return false
318 }
319 after.revision++
320 s.mu.Unlock()
321 return ac.publishViewIfCurrent(before, after)
322}
323
324// prepareAttachmentViewsForRemovedTabLocked selects the nearest surviving tab
325// for attachments that were viewing removed. Caller holds s.mu before tabs is
326// mutated; the normal invalidation pass publishes the revised views.
327func (s *session) prepareAttachmentViewsForRemovedTabLocked(removed *tab, index int) {
328 if s == nil || removed == nil || len(s.tabs) <= 1 || index < 0 || index >= len(s.tabs) {
329 return
330 }
331 replacement := index + 1
332 if replacement >= len(s.tabs) {
333 replacement = index - 1
334 }
335 if replacement < 0 || replacement >= len(s.tabs) || s.tabs[replacement] == nil {
336 return
337 }
338 tabID := domain.TabStableID(s.tabs[replacement].stableID)
339 removedID := domain.TabStableID(removed.stableID)
340 for ac := range s.attachments {
341 view := ac.viewSnapshot()
342 if view.tabID == removedID {
343 view.tabID = tabID
344 view.paneID = ""
345 view.bookmark = 0
346 view.liveBottom = true
347 view.revision++
348 ac.publishView(view)
349 }
350 }
351}
352
353// invalidateViewsLocked orders all per-attachment invalidations behind the
354// shared session mutation. Caller holds s.mu and dispatchMu.
355func (s *session) invalidateViewsLocked() {
356 for _, ac := range s.snapshotAttachmentsLocked() {
357 view := ac.viewSnapshot()
358 repaired := s.repairAttachmentViewLocked(ac, view)
359 repaired.revision++
360 ac.publishView(repaired)
361 }
362}
363
364// runMutation is the one admission boundary for shared session mutations.
365// Callers perform their state mutation and view invalidation inside fn; the
366// dispatch lock is acquired before any session, tab, or daemon lock.
367func (s *session) runMutation(fn func() error) error {
368 if s == nil || fn == nil {
369 return nil
370 }
371 s.dispatchMu.Lock()
372 defer s.dispatchMu.Unlock()
373 return fn()
374}
375
376// tabForAttachment resolves the attachment's stable target after validating
377// it against current membership. It never changes another attachment's view.
378func (s *session) tabForAttachment(ac *attachedClient) *tab {
379 if s == nil || ac == nil {
380 return nil
381 }
382 for {
383 view := ac.viewSnapshot()
384 s.mu.Lock()
385 if _, ok := s.attachments[ac]; !ok {
386 s.mu.Unlock()
387 return nil
388 }
389 repaired := s.repairAttachmentViewLocked(ac, view)
390 var target *tab
391 for _, tb := range s.tabs {
392 if tb != nil && domain.TabStableID(tb.stableID) == repaired.tabID {
393 target = tb
394 break
395 }
396 }
397 s.mu.Unlock()
398
399 if repaired.tabID != view.tabID || repaired.paneID != view.paneID || repaired.windowTop != view.windowTop || repaired.windowRows != view.windowRows || repaired.windowSet != view.windowSet || repaired.bookmark != view.bookmark || repaired.liveBottom != view.liveBottom {
400 repaired.revision++
401 }
402 if !ac.publishViewIfCurrent(view, repaired) {
403 continue
404 }
405 return target
406 }
407}
408
409// paneForAttachment resolves both stable IDs and repairs a removed pane before
410// returning it. The tab remains shared; only the attachment's view moves.
411func (s *session) tabIndexForAttachment(ac *attachedClient) (int, int) {
412 if s == nil {
413 return -1, 0
414 }
415 s.mu.Lock()
416 tabs := append([]*tab(nil), s.tabs...)
417 s.mu.Unlock()
418 if len(tabs) == 0 {
419 return -1, 0
420 }
421 if ac == nil {
422 return 0, len(tabs)
423 }
424 view := ac.viewSnapshot()
425 for i, tb := range tabs {
426 if tb != nil && domain.TabStableID(tb.stableID) == view.tabID {
427 return i, len(tabs)
428 }
429 }
430 return 0, len(tabs)
431}
432
433// firstTab returns the first tab in the session's ordered tab collection.
434// It is a deterministic session-level default for headless work, never an
435// interactive attachment selection.
436func (s *session) firstTab() *tab {
437 if s == nil {
438 return nil
439 }
440 s.mu.Lock()
441 defer s.mu.Unlock()
442 if len(s.tabs) == 0 {
443 return nil
444 }
445 return s.tabs[0]
446}
447
448func (s *session) paneForAttachment(ac *attachedClient) (*tab, *pane) {
449 tb := s.tabForAttachment(ac)
450 if tb == nil {
451 return nil, nil
452 }
453 view := ac.viewSnapshot()
454 tb.mu.Lock()
455 defer tb.mu.Unlock()
456 for _, p := range tb.panes {
457 if p != nil && domain.PaneStableID(p.stableID) == view.paneID {
458 return tb, p
459 }
460 }
461 return tb, tb.focusedPane()
462}
463
464// setAttachmentPaneLocked publishes the initiating attachment's pane target
465// after a shared focus mutation. Caller holds s.mu; the structural tree focus
466// is not used to resolve any other attachment's target.
467func (s *session) setAttachmentPaneLocked(ac *attachedClient, tb *tab, p *pane) bool {
468 if s == nil || ac == nil || tb == nil || p == nil || !attachmentRegisteredLocked(s, ac) {
469 return false
470 }
471 view := ac.viewSnapshot()
472 before := view
473 view.tabID = domain.TabStableID(tb.stableID)
474 view.paneID = domain.PaneStableID(p.stableID)
475 view = s.repairAttachmentViewLocked(ac, view)
476 if view.tabID == before.tabID && view.paneID == before.paneID {
477 return false
478 }
479 view.revision++
480 ac.publishView(view)
481 return true
482}
483
484// updateAttachmentView applies one attachment-local view mutation at the
485// shared mutation boundary. The revision increments exactly once and target
486// IDs are repaired before publication.
487func (s *session) updateAttachmentView(ac *attachedClient, update func(*attachmentView)) bool {
488 if s == nil || ac == nil || update == nil {
489 return false
490 }
491 changed := false
492 _ = s.runMutation(func() error {
493 s.mu.Lock()
494 defer s.mu.Unlock()
495 if _, ok := s.attachments[ac]; !ok {
496 return nil
497 }
498 before := ac.viewSnapshot()
499 next := before
500 update(&next)
501 next = s.repairAttachmentViewLocked(ac, next)
502 next.revision++
503 ac.publishView(next)
504 changed = next.tabID != before.tabID || next.paneID != before.paneID || next.windowTop != before.windowTop || next.windowRows != before.windowRows || next.windowSet != before.windowSet || next.bookmark != before.bookmark || next.liveBottom != before.liveBottom
505 return nil
506 })
507 return changed
508}
509
510// selectAttachmentTab changes only one attachment's target. Shared tab order
511// and PTY ownership are untouched.
512func (s *session) activateAttachmentViewLocked(ac *attachedClient, idx int) bool {
513 if s == nil || ac == nil || idx < 0 || idx >= len(s.tabs) || !attachmentRegisteredLocked(s, ac) {
514 return false
515 }
516 view := ac.viewSnapshot()
517 view.tabID = domain.TabStableID(s.tabs[idx].stableID)
518 view.paneID = ""
519 view = s.repairAttachmentViewLocked(ac, view)
520 view.revision++
521 ac.publishView(view)
522 return true
523}
524
525func (s *session) selectAttachmentTabLocked(ac *attachedClient, tabID domain.TabStableID) bool {
526 if s == nil || ac == nil || !attachmentRegisteredLocked(s, ac) {
527 return false
528 }
529 view := ac.viewSnapshot()
530 view.tabID = tabID
531 view.paneID = ""
532 view = s.repairAttachmentViewLocked(ac, view)
533 view.revision++
534 ac.publishView(view)
535 s.invalidateViewsLocked()
536 return view.tabID == tabID
537}
538
539func (s *session) selectAttachmentTab(ac *attachedClient, tabID domain.TabStableID) bool {
540 if s == nil || ac == nil {
541 return false
542 }
543 var changed bool
544 _ = s.runMutation(func() error {
545 s.mu.Lock()
546 defer s.mu.Unlock()
547 changed = s.selectAttachmentTabLocked(ac, tabID)
548 return nil
549 })
550 return changed
551}
552
553func (s *session) switchAttachmentTabForDispatch(ac *attachedClient, idx int) bool {
554 if s == nil || ac == nil {
555 return false
556 }
557 s.mu.Lock()
558 defer s.mu.Unlock()
559 if idx < 0 || idx >= len(s.tabs) {
560 return false
561 }
562 return s.selectAttachmentTabLocked(ac, domain.TabStableID(s.tabs[idx].stableID))
563}
564
565func (s *session) switchAttachmentTab(ac *attachedClient, idx int) bool {
566 if s == nil || ac == nil {
567 return false
568 }
569 s.mu.Lock()
570 registered := attachmentRegisteredLocked(s, ac)
571 if registered && idx >= 0 && idx < len(s.tabs) {
572 tabID := domain.TabStableID(s.tabs[idx].stableID)
573 s.mu.Unlock()
574 return s.selectAttachmentTab(ac, tabID)
575 }
576 s.mu.Unlock()
577 return false
578}
579
580func (s *session) switchAttachmentRelativeForDispatch(ac *attachedClient, delta int) bool {
581 position, count := s.tabIndexForAttachment(ac)
582 if count < 2 {
583 return false
584 }
585 return s.switchAttachmentTabForDispatch(ac, (position+delta+count)%count)
586}
587
588// repairAttachmentViews repairs all live attachment targets after a shared
589// topology mutation.
590func (s *session) repairAttachmentViews() {
591 if s == nil {
592 return
593 }
594 _ = s.runMutation(func() error {
595 s.mu.Lock()
596 s.invalidateViewsLocked()
597 s.mu.Unlock()
598 return nil
599 })
600}
601
602// repairAttachmentViewsLocked repairs all live attachment targets after a tab
603// or pane mutation. It is intended to run at the same mutation linearization
604// point as the shared membership change.
605func (s *session) repairAttachmentViewsLocked() {
606 if s == nil {
607 return
608 }
609 s.invalidateViewsLocked()
610}