detached db24042

refactor(daemon): route callers through attachments

commit
db24042dff55e4d00faef86d50476887bce0f0b7
author
brice · · 2026-08-02 23:53 UTC
committer
brice · · 2026-08-03 20:53 UTC
parent
8f5598a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
48 files · +568 -519
M internal/usecase/daemon/attachment_registry.go +121 -5
@@ -128,10 +128,6 @@ func (c *sessionCore) unregisterAttachmentLocked(ac *attachedClient) bool {
128128 }
129129 delete(c.attachments, ac)
130130 delete(c.attachmentOrder, ac)
131- delete(c.snatched, ac)
132- if c.client == ac {
133- c.client = nil
134- }
135131 return true
136132 }
137133
@@ -155,6 +151,17 @@ func (s *session) unregisterAttachment(ac *attachedClient) bool {
155151
156152 // snapshotAttachmentsLocked returns a stable registration-ordered snapshot.
157153 // Callers hold s.mu; returned attachments may be used after unlocking.
154+func (c *sessionCore) snapshotAttachmentsLocked() []*attachedClient {
155+ if c == nil || len(c.attachments) == 0 {
156+ return nil
157+ }
158+ out := make([]*attachedClient, 0, len(c.attachments))
159+ for ac := range c.attachments {
160+ out = append(out, ac)
161+ }
162+ return out
163+}
164+
158165 func (s *session) snapshotAttachmentsLocked() []*attachedClient {
159166 if s == nil || len(s.attachments) == 0 {
160167 return nil
@@ -174,6 +181,46 @@ func (s *session) snapshotAttachmentsLocked() []*attachedClient {
174181 return out
175182 }
176183
184+func attachmentRegisteredLocked(entry attachmentSession, ac *attachedClient) bool {
185+ if entry == nil || ac == nil || entry.core() == nil {
186+ return false
187+ }
188+ _, ok := entry.core().attachments[ac]
189+ return ok
190+}
191+
192+func attachmentRegistered(entry attachmentSession, ac *attachedClient) bool {
193+ if entry == nil || ac == nil || entry.core() == nil {
194+ return false
195+ }
196+ core := entry.core()
197+ core.mu.Lock()
198+ defer core.mu.Unlock()
199+ return attachmentRegisteredLocked(entry, ac)
200+}
201+
202+func (s *session) attachmentViewsTabLocked(tb *tab) bool {
203+ if s == nil || tb == nil {
204+ return false
205+ }
206+ for ac := range s.attachments {
207+ if ac.viewSnapshot().tabID == domain.TabStableID(tb.stableID) {
208+ return true
209+ }
210+ }
211+ return false
212+}
213+
214+func snapshotAttachmentSession(entry attachmentSession) []*attachedClient {
215+ if entry == nil || entry.core() == nil {
216+ return nil
217+ }
218+ core := entry.core()
219+ core.mu.Lock()
220+ defer core.mu.Unlock()
221+ return core.snapshotAttachmentsLocked()
222+}
223+
177224 func (s *session) snapshotAttachments() []*attachedClient {
178225 if s == nil {
179226 return nil
@@ -335,11 +382,43 @@ func (s *session) tabForAttachment(ac *attachedClient) *tab {
335382
336383 // paneForAttachment resolves both stable IDs and repairs a removed pane before
337384 // returning it. The tab remains shared; only the attachment's view moves.
385+func (s *session) tabIndexForAttachment(ac *attachedClient) (int, int) {
386+ if s == nil {
387+ return -1, 0
388+ }
389+ s.mu.Lock()
390+ tabs := append([]*tab(nil), s.tabs...)
391+ s.mu.Unlock()
392+ if len(tabs) == 0 {
393+ return -1, 0
394+ }
395+ if ac == nil {
396+ return 0, len(tabs)
397+ }
398+ view := ac.viewSnapshot()
399+ for i, tb := range tabs {
400+ if tb != nil && domain.TabStableID(tb.stableID) == view.tabID {
401+ return i, len(tabs)
402+ }
403+ }
404+ return 0, len(tabs)
405+}
406+
338407 func (s *session) tabForAttachmentOrActive(ac *attachedClient) *tab {
339408 if tb := s.tabForAttachment(ac); tb != nil {
340409 return tb
341410 }
342- return s.activeTab()
411+ // Headless/session-wide callers have no attachment view. Use the first
412+ // shared tab rather than a session-wide active-tab singleton.
413+ if s == nil {
414+ return nil
415+ }
416+ s.mu.Lock()
417+ defer s.mu.Unlock()
418+ if len(s.tabs) == 0 {
419+ return nil
420+ }
421+ return s.tabs[0]
343422 }
344423
345424 func (s *session) paneForAttachment(ac *attachedClient) (*tab, *pane) {
@@ -386,6 +465,19 @@ func (s *session) updateAttachmentView(ac *attachedClient, update func(*attachme
386465
387466 // selectAttachmentTab changes only one attachment's target. Shared tab order
388467 // and PTY ownership are untouched.
468+func (s *session) activateAttachmentViewLocked(ac *attachedClient, idx int) bool {
469+ if s == nil || ac == nil || idx < 0 || idx >= len(s.tabs) || !attachmentRegisteredLocked(s, ac) {
470+ return false
471+ }
472+ view := ac.viewSnapshot()
473+ view.tabID = domain.TabStableID(s.tabs[idx].stableID)
474+ view.paneID = ""
475+ view = s.repairAttachmentViewLocked(ac, view)
476+ view.revision++
477+ ac.publishView(view)
478+ return true
479+}
480+
389481 func (s *session) selectAttachmentTab(ac *attachedClient, tabID domain.TabStableID) bool {
390482 if s == nil || ac == nil {
391483 return false
@@ -410,6 +502,14 @@ func (s *session) selectAttachmentTab(ac *attachedClient, tabID domain.TabStable
410502 return changed
411503 }
412504
505+func (s *session) switchAttachmentRelative(ac *attachedClient, delta int) bool {
506+ position, count := s.tabIndexForAttachment(ac)
507+ if count < 2 {
508+ return false
509+ }
510+ return s.switchAttachmentTab(ac, (position+delta+count)%count)
511+}
512+
413513 func (s *session) switchAttachmentTab(ac *attachedClient, idx int) bool {
414514 if s == nil || ac == nil {
415515 return false
@@ -428,6 +528,22 @@ func (s *session) switchAttachmentTab(ac *attachedClient, idx int) bool {
428528 return s.switchTab(idx)
429529 }
430530
531+// repairAttachmentViews repairs all live attachment targets after a shared
532+// topology mutation.
533+func (s *session) repairAttachmentViews() []viewInvalidation {
534+ if s == nil {
535+ return nil
536+ }
537+ var invalidations []viewInvalidation
538+ _ = s.runMutation(func() error {
539+ s.mu.Lock()
540+ invalidations = s.invalidateViewsLocked()
541+ s.mu.Unlock()
542+ return nil
543+ })
544+ return invalidations
545+}
546+
431547 // repairAttachmentViewsLocked repairs all live attachment targets after a tab
432548 // or pane mutation. It is intended to run at the same mutation linearization
433549 // point as the shared membership change.
@@ -128,10 +128,6 @@ func (c *sessionCore) unregisterAttachmentLocked(ac *attachedClient) bool {
128 }128 }
129 delete(c.attachments, ac)129 delete(c.attachments, ac)
130 delete(c.attachmentOrder, ac)130 delete(c.attachmentOrder, ac)
131- delete(c.snatched, ac)
132- if c.client == ac {
133- c.client = nil
134- }
135 return true131 return true
136 }132 }
137 133
@@ -155,6 +151,17 @@ func (s *session) unregisterAttachment(ac *attachedClient) bool {
155 151
156 // snapshotAttachmentsLocked returns a stable registration-ordered snapshot.152 // snapshotAttachmentsLocked returns a stable registration-ordered snapshot.
157 // Callers hold s.mu; returned attachments may be used after unlocking.153 // Callers hold s.mu; returned attachments may be used after unlocking.
154+func (c *sessionCore) snapshotAttachmentsLocked() []*attachedClient {
155+ if c == nil || len(c.attachments) == 0 {
156+ return nil
157+ }
158+ out := make([]*attachedClient, 0, len(c.attachments))
159+ for ac := range c.attachments {
160+ out = append(out, ac)
161+ }
162+ return out
163+}
164+
158 func (s *session) snapshotAttachmentsLocked() []*attachedClient {165 func (s *session) snapshotAttachmentsLocked() []*attachedClient {
159 if s == nil || len(s.attachments) == 0 {166 if s == nil || len(s.attachments) == 0 {
160 return nil167 return nil
@@ -174,6 +181,46 @@ func (s *session) snapshotAttachmentsLocked() []*attachedClient {
174 return out181 return out
175 }182 }
176 183
184+func attachmentRegisteredLocked(entry attachmentSession, ac *attachedClient) bool {
185+ if entry == nil || ac == nil || entry.core() == nil {
186+ return false
187+ }
188+ _, ok := entry.core().attachments[ac]
189+ return ok
190+}
191+
192+func attachmentRegistered(entry attachmentSession, ac *attachedClient) bool {
193+ if entry == nil || ac == nil || entry.core() == nil {
194+ return false
195+ }
196+ core := entry.core()
197+ core.mu.Lock()
198+ defer core.mu.Unlock()
199+ return attachmentRegisteredLocked(entry, ac)
200+}
201+
202+func (s *session) attachmentViewsTabLocked(tb *tab) bool {
203+ if s == nil || tb == nil {
204+ return false
205+ }
206+ for ac := range s.attachments {
207+ if ac.viewSnapshot().tabID == domain.TabStableID(tb.stableID) {
208+ return true
209+ }
210+ }
211+ return false
212+}
213+
214+func snapshotAttachmentSession(entry attachmentSession) []*attachedClient {
215+ if entry == nil || entry.core() == nil {
216+ return nil
217+ }
218+ core := entry.core()
219+ core.mu.Lock()
220+ defer core.mu.Unlock()
221+ return core.snapshotAttachmentsLocked()
222+}
223+
177 func (s *session) snapshotAttachments() []*attachedClient {224 func (s *session) snapshotAttachments() []*attachedClient {
178 if s == nil {225 if s == nil {
179 return nil226 return nil
@@ -335,11 +382,43 @@ func (s *session) tabForAttachment(ac *attachedClient) *tab {
335 382
336 // paneForAttachment resolves both stable IDs and repairs a removed pane before383 // paneForAttachment resolves both stable IDs and repairs a removed pane before
337 // returning it. The tab remains shared; only the attachment's view moves.384 // returning it. The tab remains shared; only the attachment's view moves.
385+func (s *session) tabIndexForAttachment(ac *attachedClient) (int, int) {
386+ if s == nil {
387+ return -1, 0
388+ }
389+ s.mu.Lock()
390+ tabs := append([]*tab(nil), s.tabs...)
391+ s.mu.Unlock()
392+ if len(tabs) == 0 {
393+ return -1, 0
394+ }
395+ if ac == nil {
396+ return 0, len(tabs)
397+ }
398+ view := ac.viewSnapshot()
399+ for i, tb := range tabs {
400+ if tb != nil && domain.TabStableID(tb.stableID) == view.tabID {
401+ return i, len(tabs)
402+ }
403+ }
404+ return 0, len(tabs)
405+}
406+
338 func (s *session) tabForAttachmentOrActive(ac *attachedClient) *tab {407 func (s *session) tabForAttachmentOrActive(ac *attachedClient) *tab {
339 if tb := s.tabForAttachment(ac); tb != nil {408 if tb := s.tabForAttachment(ac); tb != nil {
340 return tb409 return tb
341 }410 }
342- return s.activeTab()411+ // Headless/session-wide callers have no attachment view. Use the first
412+ // shared tab rather than a session-wide active-tab singleton.
413+ if s == nil {
414+ return nil
415+ }
416+ s.mu.Lock()
417+ defer s.mu.Unlock()
418+ if len(s.tabs) == 0 {
419+ return nil
420+ }
421+ return s.tabs[0]
343 }422 }
344 423
345 func (s *session) paneForAttachment(ac *attachedClient) (*tab, *pane) {424 func (s *session) paneForAttachment(ac *attachedClient) (*tab, *pane) {
@@ -386,6 +465,19 @@ func (s *session) updateAttachmentView(ac *attachedClient, update func(*attachme
386 465
387 // selectAttachmentTab changes only one attachment's target. Shared tab order466 // selectAttachmentTab changes only one attachment's target. Shared tab order
388 // and PTY ownership are untouched.467 // and PTY ownership are untouched.
468+func (s *session) activateAttachmentViewLocked(ac *attachedClient, idx int) bool {
469+ if s == nil || ac == nil || idx < 0 || idx >= len(s.tabs) || !attachmentRegisteredLocked(s, ac) {
470+ return false
471+ }
472+ view := ac.viewSnapshot()
473+ view.tabID = domain.TabStableID(s.tabs[idx].stableID)
474+ view.paneID = ""
475+ view = s.repairAttachmentViewLocked(ac, view)
476+ view.revision++
477+ ac.publishView(view)
478+ return true
479+}
480+
389 func (s *session) selectAttachmentTab(ac *attachedClient, tabID domain.TabStableID) bool {481 func (s *session) selectAttachmentTab(ac *attachedClient, tabID domain.TabStableID) bool {
390 if s == nil || ac == nil {482 if s == nil || ac == nil {
391 return false483 return false
@@ -410,6 +502,14 @@ func (s *session) selectAttachmentTab(ac *attachedClient, tabID domain.TabStable
410 return changed502 return changed
411 }503 }
412 504
505+func (s *session) switchAttachmentRelative(ac *attachedClient, delta int) bool {
506+ position, count := s.tabIndexForAttachment(ac)
507+ if count < 2 {
508+ return false
509+ }
510+ return s.switchAttachmentTab(ac, (position+delta+count)%count)
511+}
512+
413 func (s *session) switchAttachmentTab(ac *attachedClient, idx int) bool {513 func (s *session) switchAttachmentTab(ac *attachedClient, idx int) bool {
414 if s == nil || ac == nil {514 if s == nil || ac == nil {
415 return false515 return false
@@ -428,6 +528,22 @@ func (s *session) switchAttachmentTab(ac *attachedClient, idx int) bool {
428 return s.switchTab(idx)528 return s.switchTab(idx)
429 }529 }
430 530
531+// repairAttachmentViews repairs all live attachment targets after a shared
532+// topology mutation.
533+func (s *session) repairAttachmentViews() []viewInvalidation {
534+ if s == nil {
535+ return nil
536+ }
537+ var invalidations []viewInvalidation
538+ _ = s.runMutation(func() error {
539+ s.mu.Lock()
540+ invalidations = s.invalidateViewsLocked()
541+ s.mu.Unlock()
542+ return nil
543+ })
544+ return invalidations
545+}
546+
431 // repairAttachmentViewsLocked repairs all live attachment targets after a tab547 // repairAttachmentViewsLocked repairs all live attachment targets after a tab
432 // or pane mutation. It is intended to run at the same mutation linearization548 // or pane mutation. It is intended to run at the same mutation linearization
433 // point as the shared membership change.549 // point as the shared membership change.
M internal/usecase/daemon/attachment_role.go +5 -12
@@ -39,27 +39,20 @@ func attachmentSessionRoleLocked(entry attachmentSession, ac *attachedClient) at
3939 return attachmentDetached
4040 }
4141 core := entry.core()
42- // Membership is the lifecycle authority. Keep the primary pointer as a
43- // narrow construction-time fallback for older headless fixtures; attached
44- // routes always publish into the collection.
45- if _, ok := core.attachments[ac]; ok || core.client == ac {
42+ // Membership is the lifecycle authority for every independent attachment.
43+ if _, ok := core.attachments[ac]; ok {
4644 return attachmentActive
4745 }
48- if _, ok := core.snatched[ac]; ok {
49- return attachmentSnatched
50- }
5146 return attachmentDetached
5247 }
5348
5449 func addSnatchedLocked(entry attachmentSession, ac *attachedClient) {
50+ // Role-preserving parking is attachment-local. Session membership is enough
51+ // to admit the connection; there is no session-wide snatched owner.
5552 if entry == nil || ac == nil || entry.core() == nil {
5653 return
5754 }
58- core := entry.core()
59- if core.snatched == nil {
60- core.snatched = make(map[*attachedClient]struct{})
61- }
62- core.snatched[ac] = struct{}{}
55+ entry.core().registerAttachmentLocked(ac)
6356 }
6457
6558 func (s *session) attachmentRole(ac *attachedClient) attachmentRole {
@@ -39,27 +39,20 @@ func attachmentSessionRoleLocked(entry attachmentSession, ac *attachedClient) at
39 return attachmentDetached39 return attachmentDetached
40 }40 }
41 core := entry.core()41 core := entry.core()
42- // Membership is the lifecycle authority. Keep the primary pointer as a42+ // Membership is the lifecycle authority for every independent attachment.
43- // narrow construction-time fallback for older headless fixtures; attached43+ if _, ok := core.attachments[ac]; ok {
44- // routes always publish into the collection.
45- if _, ok := core.attachments[ac]; ok || core.client == ac {
46 return attachmentActive44 return attachmentActive
47 }45 }
48- if _, ok := core.snatched[ac]; ok {
49- return attachmentSnatched
50- }
51 return attachmentDetached46 return attachmentDetached
52 }47 }
53 48
54 func addSnatchedLocked(entry attachmentSession, ac *attachedClient) {49 func addSnatchedLocked(entry attachmentSession, ac *attachedClient) {
50+ // Role-preserving parking is attachment-local. Session membership is enough
51+ // to admit the connection; there is no session-wide snatched owner.
55 if entry == nil || ac == nil || entry.core() == nil {52 if entry == nil || ac == nil || entry.core() == nil {
56 return53 return
57 }54 }
58- core := entry.core()55+ entry.core().registerAttachmentLocked(ac)
59- if core.snatched == nil {
60- core.snatched = make(map[*attachedClient]struct{})
61- }
62- core.snatched[ac] = struct{}{}
63 }56 }
64 57
65 func (s *session) attachmentRole(ac *attachedClient) attachmentRole {58 func (s *session) attachmentRole(ac *attachedClient) attachmentRole {
M internal/usecase/daemon/attachment_transition.go +5 -17
@@ -109,28 +109,16 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (
109109 d.sessions[sourceCore.id] != source || req.target != nil && d.sessions[targetCore.id] != req.target {
110110 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
111111 }
112- if req.target != nil {
113- targetCore.mu.Lock()
114- req.expectedTargetCurrent = targetCore.client
115- if req.expectedTargetCurrent != nil && req.expectedTargetCurrent != req.next && req.targetRole == attachmentActive {
116- req.expectedTargetTransport = req.expectedTargetCurrent.transportSnapshot()
117- }
118- targetCore.mu.Unlock()
119- }
112+ // Existing attachments remain independent session members. Transitions only
113+ // publish the initiating attachment; there is no singleton target owner to
114+ // displace or interrupt.
115+ req.expectedTargetCurrent = nil
120116 if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) {
121117 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
122118 }
123119 req.preflighted = true
124120 participants := attachmentTransitionParticipants{clients: []*attachedClient{req.next}}
125- // A new attachment joins a session; it does not displace or interrupt an
126- // existing attachment. Handoff callers provide sourceToken and retain the
127- // legacy target participant set for their cross-session publication.
128- if req.source != nil || req.action != "" {
129- participants.clients = append(participants.clients, req.expectedTargetCurrent)
130- participants.interrupts = append(participants.interrupts, roleTransportInterrupt{
131- ac: req.expectedTargetCurrent, transport: req.expectedTargetTransport,
132- })
133- }
121+ // Every attachment joins without displacing another connection.
134122 return req, participants, nil
135123 }
136124
@@ -109,28 +109,16 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (
109 d.sessions[sourceCore.id] != source || req.target != nil && d.sessions[targetCore.id] != req.target {109 d.sessions[sourceCore.id] != source || req.target != nil && d.sessions[targetCore.id] != req.target {
110 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition110 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
111 }111 }
112- if req.target != nil {112+ // Existing attachments remain independent session members. Transitions only
113- targetCore.mu.Lock()113+ // publish the initiating attachment; there is no singleton target owner to
114- req.expectedTargetCurrent = targetCore.client114+ // displace or interrupt.
115- if req.expectedTargetCurrent != nil && req.expectedTargetCurrent != req.next && req.targetRole == attachmentActive {115+ req.expectedTargetCurrent = nil
116- req.expectedTargetTransport = req.expectedTargetCurrent.transportSnapshot()
117- }
118- targetCore.mu.Unlock()
119- }
120 if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) {116 if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) {
121 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition117 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
122 }118 }
123 req.preflighted = true119 req.preflighted = true
124 participants := attachmentTransitionParticipants{clients: []*attachedClient{req.next}}120 participants := attachmentTransitionParticipants{clients: []*attachedClient{req.next}}
125- // A new attachment joins a session; it does not displace or interrupt an121+ // Every attachment joins without displacing another connection.
126- // existing attachment. Handoff callers provide sourceToken and retain the
127- // legacy target participant set for their cross-session publication.
128- if req.source != nil || req.action != "" {
129- participants.clients = append(participants.clients, req.expectedTargetCurrent)
130- participants.interrupts = append(participants.interrupts, roleTransportInterrupt{
131- ac: req.expectedTargetCurrent, transport: req.expectedTargetTransport,
132- })
133- }
134 return req, participants, nil122 return req, participants, nil
135 }123 }
136 124
M internal/usecase/daemon/attachment_transition_publish.go +32 -52
@@ -1,6 +1,10 @@
11 package daemon
22
3-import "github.com/bnema/vev/internal/domain"
3+import (
4+ "slices"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
48
59 // transitionSourcePreflightLocked validates an initiating role while its gate
610 // is frozen and d.mu is held. No caller may create or delete a target before
@@ -60,7 +64,7 @@ func transitionSourceTabCurrentForRequestLocked(source attachmentSession, expect
6064 return true
6165 }
6266 local, ok := localSession(source)
63- if !ok || local.active < 0 || local.active >= len(local.tabs) || local.tabs[local.active] != expected {
67+ if !ok || !slices.Contains(local.tabs, expected) {
6468 return false
6569 }
6670 expected.mu.Lock()
@@ -141,14 +145,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
141145
142146 var targetCoordinator *renderCoordinator
143147 if req.targetRole == attachmentActive {
144- // The coordinator remains session-owned while attachments join the
145- // session. Only the first attachment claims its primary render lease;
146- // later attachments must not replace or invalidate it.
147- if targetCore.client == nil {
148- targetCoordinator = d.ensureAttachmentRenderCoordinatorPrelocked(req.target)
149- } else {
150- targetCoordinator = targetCore.coordinator.Load()
151- }
148+ targetCoordinator = d.ensureAttachmentRenderCoordinatorPrelocked(req.target)
152149 }
153150 sourceCoordinator := sourceCore.coordinator.Load()
154151 if attachmentSessionRoleLocked(source, req.next) != req.expectedRole ||
@@ -156,8 +153,8 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
156153 return nil, errAttachmentTransition
157154 }
158155
159- old := targetCore.client
160- if old != req.expectedTargetCurrent || source != req.target && old == req.next {
156+ old := (*attachedClient)(nil)
157+ if req.expectedTargetCurrent != nil {
161158 return nil, errAttachmentTransition
162159 }
163160 publication := &attachmentPublication{
@@ -167,7 +164,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
167164 }
168165 invalid := req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) ||
169166 !transitionSourceTabCurrentForRequestLocked(source, req.expectedSourceTab, req.transferExpectedSourceTab))
170- invalid = invalid || targetCoordinator != nil && old == nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next)
167+ invalid = invalid || targetCoordinator != nil && targetCoordinator.lease == nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next)
171168 invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&
172169 (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next)
173170 invalid = invalid || !attachmentLifecycleCurrentLocked(req.target, req.expectedTargetLifecycle)
@@ -183,6 +180,11 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
183180 // part of the same locked publication. It reports role-preserving transitions.
184181 func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool {
185182 req := publication.req
183+ if req.activateTargetTab {
184+ if target, ok := localSession(req.target); ok {
185+ target.activateAttachmentViewLocked(req.next, req.targetTabIndex)
186+ }
187+ }
186188 if req.copySourceEnvironment {
187189 source, sourceOK := localSession(publication.source)
188190 target, targetOK := localSession(req.target)
@@ -198,28 +200,11 @@ func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool
198200 // generations, and currentSession while all role gates remain frozen.
199201 func publishAttachmentOwnershipLocked(publication *attachmentPublication) {
200202 req := publication.req
201- // Membership is collection-owned and is published before role metadata. A
202- // second attachment therefore coexists with the first even when the legacy
203- // active/snatched handoff state is also updated for older render paths.
204- registerAttachmentSessionLocked(publication.source, req.next)
203+ // Session membership is the sole routing publication. Existing attachments
204+ // remain registered and retain their independent views and transports.
205205 registerAttachmentSessionLocked(req.target, req.next)
206- if publication.old != nil && publication.old != req.next {
207- // Existing attachments remain registered and retain their transport and
208- // generation. A new attach is a collection insertion, never a
209- // replacement handoff.
210- registerAttachmentSessionLocked(req.target, publication.old)
211- }
212- if publication.source != req.target && publication.source.core().client == req.next {
213- publication.source.core().client = nil
214- }
215- delete(publication.source.core().snatched, req.next)
216- if req.targetRole == attachmentActive {
217- delete(req.target.core().snatched, req.next)
218- if req.target.core().client == nil {
219- req.target.core().client = req.next
220- }
221- } else {
222- addSnatchedLocked(req.target, req.next)
206+ if publication.source != req.target {
207+ unregisterAttachmentSessionLocked(publication.source, req.next)
223208 }
224209 publication.nextGeneration = req.next.roleGeneration.Add(1)
225210 req.next.setSession(req.target)
@@ -234,10 +219,16 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att
234219 result.cleanups = append(result.cleanups, publication.sourceCoordinator.beginDetachLocked(req.next))
235220 }
236221 var lease *attachmentLease
237- if publication.targetCoordinator != nil && publication.old == nil {
222+ if publication.targetCoordinator != nil {
238223 var cleanup renderLifecycleCleanup
239- cleanup, lease = publication.targetCoordinator.beginReplaceLocked(publication.old, req.next, req.ready)
240- result.cleanups = append(result.cleanups, cleanup)
224+ if publication.targetCoordinator.lease == nil {
225+ cleanup, lease = publication.targetCoordinator.beginReplaceLocked(nil, req.next, req.ready)
226+ result.cleanups = append(result.cleanups, cleanup)
227+ } else {
228+ // Secondary attachments use membership admission; the coordinator's
229+ // optional primary lease is not reused by another attachment.
230+ lease = nil
231+ }
241232 }
242233 result.published = attachmentRoleToken{
243234 sess: req.target,
@@ -248,20 +239,7 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att
248239 lease: lease,
249240 rebase: publication.source != req.target && req.expectedRole == attachmentActive,
250241 }
251- if publication.displacedGeneration != 0 {
252- if publication.terminalDisplaced {
253- result.terminalDisplaced = detachedAttachmentSnapshot{ac: publication.old, transport: publication.displacedTransport}
254- } else {
255- result.displaced = attachmentRoleToken{
256- sess: req.target,
257- ac: publication.old,
258- role: attachmentSnatched,
259- generation: publication.displacedGeneration,
260- transport: publication.displacedTransport,
261- }
262- result.displacedInterrupted = req.expectedTargetTransportInterrupted
263- }
264- }
242+
265243 // Membership, currentSession, lease, generation, and both exact admission
266244 // capabilities become visible as one frozen publication. The gates remain
267245 // frozen until every architecture lock has been released.
@@ -278,10 +256,12 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att
278256 // ownership, generation, and coordinator-lease publication. The caller retains
279257 // every lock required by validateAttachmentTransitionPrelocked.
280258 func (d *Daemon) publishAttachmentTransitionPrelocked(publication *attachmentPublication) attachmentTransitionResult {
281- if d.applyTargetStateLocked(publication) {
259+ if publication.req.preserveRole {
260+ d.applyTargetStateLocked(publication)
282261 return attachmentTransitionResult{published: *publication.req.sourceToken}
283262 }
284263 publishAttachmentOwnershipLocked(publication)
264+ d.applyTargetStateLocked(publication)
285265 return buildAttachmentPostcommitPlanLocked(publication)
286266 }
287267
@@ -1,6 +1,10 @@
1 package daemon1 package daemon
2 2
3-import "github.com/bnema/vev/internal/domain"3+import (
4+ "slices"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
4 8
5 // transitionSourcePreflightLocked validates an initiating role while its gate9 // transitionSourcePreflightLocked validates an initiating role while its gate
6 // is frozen and d.mu is held. No caller may create or delete a target before10 // is frozen and d.mu is held. No caller may create or delete a target before
@@ -60,7 +64,7 @@ func transitionSourceTabCurrentForRequestLocked(source attachmentSession, expect
60 return true64 return true
61 }65 }
62 local, ok := localSession(source)66 local, ok := localSession(source)
63- if !ok || local.active < 0 || local.active >= len(local.tabs) || local.tabs[local.active] != expected {67+ if !ok || !slices.Contains(local.tabs, expected) {
64 return false68 return false
65 }69 }
66 expected.mu.Lock()70 expected.mu.Lock()
@@ -141,14 +145,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
141 145
142 var targetCoordinator *renderCoordinator146 var targetCoordinator *renderCoordinator
143 if req.targetRole == attachmentActive {147 if req.targetRole == attachmentActive {
144- // The coordinator remains session-owned while attachments join the148+ targetCoordinator = d.ensureAttachmentRenderCoordinatorPrelocked(req.target)
145- // session. Only the first attachment claims its primary render lease;
146- // later attachments must not replace or invalidate it.
147- if targetCore.client == nil {
148- targetCoordinator = d.ensureAttachmentRenderCoordinatorPrelocked(req.target)
149- } else {
150- targetCoordinator = targetCore.coordinator.Load()
151- }
152 }149 }
153 sourceCoordinator := sourceCore.coordinator.Load()150 sourceCoordinator := sourceCore.coordinator.Load()
154 if attachmentSessionRoleLocked(source, req.next) != req.expectedRole ||151 if attachmentSessionRoleLocked(source, req.next) != req.expectedRole ||
@@ -156,8 +153,8 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
156 return nil, errAttachmentTransition153 return nil, errAttachmentTransition
157 }154 }
158 155
159- old := targetCore.client156+ old := (*attachedClient)(nil)
160- if old != req.expectedTargetCurrent || source != req.target && old == req.next {157+ if req.expectedTargetCurrent != nil {
161 return nil, errAttachmentTransition158 return nil, errAttachmentTransition
162 }159 }
163 publication := &attachmentPublication{160 publication := &attachmentPublication{
@@ -167,7 +164,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
167 }164 }
168 invalid := req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) ||165 invalid := req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) ||
169 !transitionSourceTabCurrentForRequestLocked(source, req.expectedSourceTab, req.transferExpectedSourceTab))166 !transitionSourceTabCurrentForRequestLocked(source, req.expectedSourceTab, req.transferExpectedSourceTab))
170- invalid = invalid || targetCoordinator != nil && old == nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next)167+ invalid = invalid || targetCoordinator != nil && targetCoordinator.lease == nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next)
171 invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&168 invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&
172 (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next)169 (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next)
173 invalid = invalid || !attachmentLifecycleCurrentLocked(req.target, req.expectedTargetLifecycle)170 invalid = invalid || !attachmentLifecycleCurrentLocked(req.target, req.expectedTargetLifecycle)
@@ -183,6 +180,11 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
183 // part of the same locked publication. It reports role-preserving transitions.180 // part of the same locked publication. It reports role-preserving transitions.
184 func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool {181 func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool {
185 req := publication.req182 req := publication.req
183+ if req.activateTargetTab {
184+ if target, ok := localSession(req.target); ok {
185+ target.activateAttachmentViewLocked(req.next, req.targetTabIndex)
186+ }
187+ }
186 if req.copySourceEnvironment {188 if req.copySourceEnvironment {
187 source, sourceOK := localSession(publication.source)189 source, sourceOK := localSession(publication.source)
188 target, targetOK := localSession(req.target)190 target, targetOK := localSession(req.target)
@@ -198,28 +200,11 @@ func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool
198 // generations, and currentSession while all role gates remain frozen.200 // generations, and currentSession while all role gates remain frozen.
199 func publishAttachmentOwnershipLocked(publication *attachmentPublication) {201 func publishAttachmentOwnershipLocked(publication *attachmentPublication) {
200 req := publication.req202 req := publication.req
201- // Membership is collection-owned and is published before role metadata. A203+ // Session membership is the sole routing publication. Existing attachments
202- // second attachment therefore coexists with the first even when the legacy204+ // remain registered and retain their independent views and transports.
203- // active/snatched handoff state is also updated for older render paths.
204- registerAttachmentSessionLocked(publication.source, req.next)
205 registerAttachmentSessionLocked(req.target, req.next)205 registerAttachmentSessionLocked(req.target, req.next)
206- if publication.old != nil && publication.old != req.next {206+ if publication.source != req.target {
207- // Existing attachments remain registered and retain their transport and207+ unregisterAttachmentSessionLocked(publication.source, req.next)
208- // generation. A new attach is a collection insertion, never a
209- // replacement handoff.
210- registerAttachmentSessionLocked(req.target, publication.old)
211- }
212- if publication.source != req.target && publication.source.core().client == req.next {
213- publication.source.core().client = nil
214- }
215- delete(publication.source.core().snatched, req.next)
216- if req.targetRole == attachmentActive {
217- delete(req.target.core().snatched, req.next)
218- if req.target.core().client == nil {
219- req.target.core().client = req.next
220- }
221- } else {
222- addSnatchedLocked(req.target, req.next)
223 }208 }
224 publication.nextGeneration = req.next.roleGeneration.Add(1)209 publication.nextGeneration = req.next.roleGeneration.Add(1)
225 req.next.setSession(req.target)210 req.next.setSession(req.target)
@@ -234,10 +219,16 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att
234 result.cleanups = append(result.cleanups, publication.sourceCoordinator.beginDetachLocked(req.next))219 result.cleanups = append(result.cleanups, publication.sourceCoordinator.beginDetachLocked(req.next))
235 }220 }
236 var lease *attachmentLease221 var lease *attachmentLease
237- if publication.targetCoordinator != nil && publication.old == nil {222+ if publication.targetCoordinator != nil {
238 var cleanup renderLifecycleCleanup223 var cleanup renderLifecycleCleanup
239- cleanup, lease = publication.targetCoordinator.beginReplaceLocked(publication.old, req.next, req.ready)224+ if publication.targetCoordinator.lease == nil {
240- result.cleanups = append(result.cleanups, cleanup)225+ cleanup, lease = publication.targetCoordinator.beginReplaceLocked(nil, req.next, req.ready)
226+ result.cleanups = append(result.cleanups, cleanup)
227+ } else {
228+ // Secondary attachments use membership admission; the coordinator's
229+ // optional primary lease is not reused by another attachment.
230+ lease = nil
231+ }
241 }232 }
242 result.published = attachmentRoleToken{233 result.published = attachmentRoleToken{
243 sess: req.target,234 sess: req.target,
@@ -248,20 +239,7 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att
248 lease: lease,239 lease: lease,
249 rebase: publication.source != req.target && req.expectedRole == attachmentActive,240 rebase: publication.source != req.target && req.expectedRole == attachmentActive,
250 }241 }
251- if publication.displacedGeneration != 0 {242+
252- if publication.terminalDisplaced {
253- result.terminalDisplaced = detachedAttachmentSnapshot{ac: publication.old, transport: publication.displacedTransport}
254- } else {
255- result.displaced = attachmentRoleToken{
256- sess: req.target,
257- ac: publication.old,
258- role: attachmentSnatched,
259- generation: publication.displacedGeneration,
260- transport: publication.displacedTransport,
261- }
262- result.displacedInterrupted = req.expectedTargetTransportInterrupted
263- }
264- }
265 // Membership, currentSession, lease, generation, and both exact admission243 // Membership, currentSession, lease, generation, and both exact admission
266 // capabilities become visible as one frozen publication. The gates remain244 // capabilities become visible as one frozen publication. The gates remain
267 // frozen until every architecture lock has been released.245 // frozen until every architecture lock has been released.
@@ -278,10 +256,12 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att
278 // ownership, generation, and coordinator-lease publication. The caller retains256 // ownership, generation, and coordinator-lease publication. The caller retains
279 // every lock required by validateAttachmentTransitionPrelocked.257 // every lock required by validateAttachmentTransitionPrelocked.
280 func (d *Daemon) publishAttachmentTransitionPrelocked(publication *attachmentPublication) attachmentTransitionResult {258 func (d *Daemon) publishAttachmentTransitionPrelocked(publication *attachmentPublication) attachmentTransitionResult {
281- if d.applyTargetStateLocked(publication) {259+ if publication.req.preserveRole {
260+ d.applyTargetStateLocked(publication)
282 return attachmentTransitionResult{published: *publication.req.sourceToken}261 return attachmentTransitionResult{published: *publication.req.sourceToken}
283 }262 }
284 publishAttachmentOwnershipLocked(publication)263 publishAttachmentOwnershipLocked(publication)
264+ d.applyTargetStateLocked(publication)
285 return buildAttachmentPostcommitPlanLocked(publication)265 return buildAttachmentPostcommitPlanLocked(publication)
286 }266 }
287 267
M internal/usecase/daemon/attention.go +13 -12
@@ -23,13 +23,16 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) {
2323 }
2424
2525 sess.mu.Lock()
26- visible := sess.client != nil && sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
2726 if !tb.attention {
2827 tb.attentionAt = now
2928 }
3029 tb.attention = true
31- if visible {
32- tb.attentionVisiblePaint = true
30+ for _, attachment := range sess.snapshotAttachmentsLocked() {
31+ view := attachment.viewSnapshot()
32+ if domain.TabStableID(tb.stableID) == view.tabID {
33+ tb.attentionVisiblePaint = true
34+ break
35+ }
3336 }
3437 sess.mu.Unlock()
3538
@@ -55,8 +58,8 @@ func (d *Daemon) jumpAttentionForRole(sess *session, ac *attachedClient, token a
5558 return nil
5659 }
5760 if idx, ok := oldestAttentionTab(sess); ok {
58- if sess.switchTab(idx) {
59- d.activateTab(sess, sess.activeTab())
61+ if sess.switchAttachmentTab(ac, idx) {
62+ d.activateTabAfterResizeForLease(sess, sess.tabForAttachmentOrActive(ac), false, ac, token.lease)
6063 d.invalidateRender(sess, ac, true, "attention.go")
6164 }
6265 return nil
@@ -127,10 +130,7 @@ func oldestAttentionTabWithTime(sess *session) (int, time.Time, bool) {
127130 }
128131
129132 func (d *Daemon) repaintAttachedClients(sess *session) {
130- sess.mu.Lock()
131- ac := sess.client
132- sess.mu.Unlock()
133- if ac != nil {
133+ for _, ac := range sess.snapshotAttachments() {
134134 d.invalidateRender(sess, ac, false, "attention.go")
135135 }
136136 }
@@ -145,13 +145,14 @@ func (d *Daemon) repaintAllAttachedClients() {
145145 }
146146 }
147147
148-func (s *session) ackAttention(tb *tab, visible bool) bool {
149- if tb == nil {
148+func (s *session) ackAttention(tb *tab, ac *attachedClient, visible bool) bool {
149+ if tb == nil || ac == nil {
150150 return false
151151 }
152152 s.mu.Lock()
153153 defer s.mu.Unlock()
154- if s.active < 0 || s.active >= len(s.tabs) || s.tabs[s.active] != tb || !tb.attention {
154+ view := ac.viewSnapshot()
155+ if domain.TabStableID(tb.stableID) != view.tabID || !tb.attention {
155156 return false
156157 }
157158 if tb.attentionVisiblePaint && !visible {
@@ -23,13 +23,16 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) {
23 }23 }
24 24
25 sess.mu.Lock()25 sess.mu.Lock()
26- visible := sess.client != nil && sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
27 if !tb.attention {26 if !tb.attention {
28 tb.attentionAt = now27 tb.attentionAt = now
29 }28 }
30 tb.attention = true29 tb.attention = true
31- if visible {30+ for _, attachment := range sess.snapshotAttachmentsLocked() {
32- tb.attentionVisiblePaint = true31+ view := attachment.viewSnapshot()
32+ if domain.TabStableID(tb.stableID) == view.tabID {
33+ tb.attentionVisiblePaint = true
34+ break
35+ }
33 }36 }
34 sess.mu.Unlock()37 sess.mu.Unlock()
35 38
@@ -55,8 +58,8 @@ func (d *Daemon) jumpAttentionForRole(sess *session, ac *attachedClient, token a
55 return nil58 return nil
56 }59 }
57 if idx, ok := oldestAttentionTab(sess); ok {60 if idx, ok := oldestAttentionTab(sess); ok {
58- if sess.switchTab(idx) {61+ if sess.switchAttachmentTab(ac, idx) {
59- d.activateTab(sess, sess.activeTab())62+ d.activateTabAfterResizeForLease(sess, sess.tabForAttachmentOrActive(ac), false, ac, token.lease)
60 d.invalidateRender(sess, ac, true, "attention.go")63 d.invalidateRender(sess, ac, true, "attention.go")
61 }64 }
62 return nil65 return nil
@@ -127,10 +130,7 @@ func oldestAttentionTabWithTime(sess *session) (int, time.Time, bool) {
127 }130 }
128 131
129 func (d *Daemon) repaintAttachedClients(sess *session) {132 func (d *Daemon) repaintAttachedClients(sess *session) {
130- sess.mu.Lock()133+ for _, ac := range sess.snapshotAttachments() {
131- ac := sess.client
132- sess.mu.Unlock()
133- if ac != nil {
134 d.invalidateRender(sess, ac, false, "attention.go")134 d.invalidateRender(sess, ac, false, "attention.go")
135 }135 }
136 }136 }
@@ -145,13 +145,14 @@ func (d *Daemon) repaintAllAttachedClients() {
145 }145 }
146 }146 }
147 147
148-func (s *session) ackAttention(tb *tab, visible bool) bool {148+func (s *session) ackAttention(tb *tab, ac *attachedClient, visible bool) bool {
149- if tb == nil {149+ if tb == nil || ac == nil {
150 return false150 return false
151 }151 }
152 s.mu.Lock()152 s.mu.Lock()
153 defer s.mu.Unlock()153 defer s.mu.Unlock()
154- if s.active < 0 || s.active >= len(s.tabs) || s.tabs[s.active] != tb || !tb.attention {154+ view := ac.viewSnapshot()
155+ if domain.TabStableID(tb.stableID) != view.tabID || !tb.attention {
155 return false156 return false
156 }157 }
157 if tb.attentionVisiblePaint && !visible {158 if tb.attentionVisiblePaint && !visible {
M internal/usecase/daemon/bar_refresh.go +6 -11
@@ -122,15 +122,11 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip
122122 // slice under sess.mu (daemon.go, resume.go), and nothing mutates the
123123 // backing array in place, so capturing the header here is safe.
124124 env := sess.env
125- active := sess.active
126- var tb *tab
127- if active >= 0 && active < len(sess.tabs) {
128- tb = sess.tabs[active]
129- }
130- ac := sess.client
125+ attachments := sess.snapshotAttachmentsLocked()
131126 ctx.PaneCWD = sess.cwd
132127 sess.mu.Unlock()
133- if ac == nil || tb == nil {
128+ tb := sess.tabForAttachmentOrActive(nil)
129+ if len(attachments) == 0 || tb == nil {
134130 return ctx, env, false
135131 }
136132 tb.mu.Lock()
@@ -413,11 +409,10 @@ func (d *Daemon) pokeSessionRender(sess *session) {
413409 if sess == nil {
414410 return
415411 }
416- sess.mu.Lock()
417- ac := sess.client
418- sess.mu.Unlock()
419412 // A headless session can still supply a picker preview to an attachment
420413 // owned by another session. Its coordinator fans this wake out to those
421414 // preview subscribers even though no primary attachment is bound.
422- d.invalidateRender(sess, ac, false, "bar_refresh.go")
415+ for _, ac := range sess.snapshotAttachments() {
416+ d.invalidateRender(sess, ac, false, "bar_refresh.go")
417+ }
423418 }
@@ -122,15 +122,11 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip
122 // slice under sess.mu (daemon.go, resume.go), and nothing mutates the122 // slice under sess.mu (daemon.go, resume.go), and nothing mutates the
123 // backing array in place, so capturing the header here is safe.123 // backing array in place, so capturing the header here is safe.
124 env := sess.env124 env := sess.env
125- active := sess.active125+ attachments := sess.snapshotAttachmentsLocked()
126- var tb *tab
127- if active >= 0 && active < len(sess.tabs) {
128- tb = sess.tabs[active]
129- }
130- ac := sess.client
131 ctx.PaneCWD = sess.cwd126 ctx.PaneCWD = sess.cwd
132 sess.mu.Unlock()127 sess.mu.Unlock()
133- if ac == nil || tb == nil {128+ tb := sess.tabForAttachmentOrActive(nil)
129+ if len(attachments) == 0 || tb == nil {
134 return ctx, env, false130 return ctx, env, false
135 }131 }
136 tb.mu.Lock()132 tb.mu.Lock()
@@ -413,11 +409,10 @@ func (d *Daemon) pokeSessionRender(sess *session) {
413 if sess == nil {409 if sess == nil {
414 return410 return
415 }411 }
416- sess.mu.Lock()
417- ac := sess.client
418- sess.mu.Unlock()
419 // A headless session can still supply a picker preview to an attachment412 // A headless session can still supply a picker preview to an attachment
420 // owned by another session. Its coordinator fans this wake out to those413 // owned by another session. Its coordinator fans this wake out to those
421 // preview subscribers even though no primary attachment is bound.414 // preview subscribers even though no primary attachment is bound.
422- d.invalidateRender(sess, ac, false, "bar_refresh.go")415+ for _, ac := range sess.snapshotAttachments() {
416+ d.invalidateRender(sess, ac, false, "bar_refresh.go")
417+ }
423 }418 }
M internal/usecase/daemon/client.go +17 -17
@@ -632,16 +632,16 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess
632632 }
633633 },
634634 ackReady: func() bool {
635- entry.core().mu.Lock()
636- attached := entry.core().client
637- entry.core().mu.Unlock()
638- if attached == nil {
639- return false
635+ attachments := snapshotAttachmentSession(entry)
636+ for _, attached := range attachments {
637+ attached.sendMu.Lock()
638+ ready := attached.output == nil || !attached.output.atCapacity()
639+ attached.sendMu.Unlock()
640+ if ready {
641+ return true
642+ }
640643 }
641- attached.sendMu.Lock()
642- ready := attached.output == nil || !attached.output.atCapacity()
643- attached.sendMu.Unlock()
644- return ready
644+ return false
645645 },
646646 })
647647 installAttachmentRenderCoordinator(entry, rc)
@@ -655,10 +655,10 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess
655655 // next client (with a different terminal theme) may never have reported.
656656 // Mirrors attachClient's reset loop: snapshot sess.tabs under sess.mu,
657657 // release it, then take each tb.mu in turn — never holding sess.mu and
658-// tb.mu together. Guarded against a race with a newer attach: if sess.client
659-// is non-nil by the time sess.mu is taken, a new client has already attached
660-// (and run its own attach-time reset), so this call must leave the tabs
661-// alone rather than clobbering that client's freshly applied colors.
658+// tb.mu together. Guarded against a race with a newer attachment: if the
659+// session has gained an attachment by the time sess.mu is taken, that
660+// attachment has already run its attach-time reset, so this call must leave
661+// the tabs alone rather than clobbering freshly applied colors.
662662 func (d *Daemon) resetScreenDefaultColors(sess *session) {
663663 d.applyHostTheme(sess, nil, themeui.Theme{}, true)
664664 }
@@ -718,7 +718,7 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi
718718 // Drained before the early return below so a session without an active tab
719719 // cannot swallow the queue.
720720 d.drainPendingForFirstPaint(sess, ac)
721- tb := sess.activeTab()
721+ tb := sess.tabForAttachmentOrActive(ac)
722722 if tb == nil {
723723 return
724724 }
@@ -800,8 +800,8 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
800800
801801 // handleClientNotice maps the closed client-event enum to daemon-owned notice
802802 // content. routingMu makes ownership validation and toast mutation one atomic
803-// attachment-routing operation: replacement also takes routingMu before
804-// publishing sess.client. Never retain sess.mu while touching notice or overlay
803+// attachment-routing operation: attachment publication also takes routingMu
804+// before changing membership. Never retain sess.mu while touching notice or overlay
805805 // state, and retain the routingMu -> sess.mu order used by attachment paths.
806806 func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) {
807807 if sess == nil || ac == nil {
@@ -846,7 +846,7 @@ func (d *Daemon) handleClientNoticeForRole(token attachmentRoleToken, notice por
846846 func (d *Daemon) handleClientNoticeDirect(sess *session, ac *attachedClient, notice ports.ClientNotice) {
847847 d.notices.routingMu.Lock()
848848 sess.mu.Lock()
849- current := sess.client == ac
849+ _, current := sess.attachments[ac]
850850 sess.mu.Unlock()
851851 if !current {
852852 d.notices.routingMu.Unlock()
@@ -632,16 +632,16 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess
632 }632 }
633 },633 },
634 ackReady: func() bool {634 ackReady: func() bool {
635- entry.core().mu.Lock()635+ attachments := snapshotAttachmentSession(entry)
636- attached := entry.core().client636+ for _, attached := range attachments {
637- entry.core().mu.Unlock()637+ attached.sendMu.Lock()
638- if attached == nil {638+ ready := attached.output == nil || !attached.output.atCapacity()
639- return false639+ attached.sendMu.Unlock()
640+ if ready {
641+ return true
642+ }
640 }643 }
641- attached.sendMu.Lock()644+ return false
642- ready := attached.output == nil || !attached.output.atCapacity()
643- attached.sendMu.Unlock()
644- return ready
645 },645 },
646 })646 })
647 installAttachmentRenderCoordinator(entry, rc)647 installAttachmentRenderCoordinator(entry, rc)
@@ -655,10 +655,10 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess
655 // next client (with a different terminal theme) may never have reported.655 // next client (with a different terminal theme) may never have reported.
656 // Mirrors attachClient's reset loop: snapshot sess.tabs under sess.mu,656 // Mirrors attachClient's reset loop: snapshot sess.tabs under sess.mu,
657 // release it, then take each tb.mu in turn — never holding sess.mu and657 // release it, then take each tb.mu in turn — never holding sess.mu and
658-// tb.mu together. Guarded against a race with a newer attach: if sess.client658+// tb.mu together. Guarded against a race with a newer attachment: if the
659-// is non-nil by the time sess.mu is taken, a new client has already attached659+// session has gained an attachment by the time sess.mu is taken, that
660-// (and run its own attach-time reset), so this call must leave the tabs660+// attachment has already run its attach-time reset, so this call must leave
661-// alone rather than clobbering that client's freshly applied colors.661+// the tabs alone rather than clobbering freshly applied colors.
662 func (d *Daemon) resetScreenDefaultColors(sess *session) {662 func (d *Daemon) resetScreenDefaultColors(sess *session) {
663 d.applyHostTheme(sess, nil, themeui.Theme{}, true)663 d.applyHostTheme(sess, nil, themeui.Theme{}, true)
664 }664 }
@@ -718,7 +718,7 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi
718 // Drained before the early return below so a session without an active tab718 // Drained before the early return below so a session without an active tab
719 // cannot swallow the queue.719 // cannot swallow the queue.
720 d.drainPendingForFirstPaint(sess, ac)720 d.drainPendingForFirstPaint(sess, ac)
721- tb := sess.activeTab()721+ tb := sess.tabForAttachmentOrActive(ac)
722 if tb == nil {722 if tb == nil {
723 return723 return
724 }724 }
@@ -800,8 +800,8 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
800 800
801 // handleClientNotice maps the closed client-event enum to daemon-owned notice801 // handleClientNotice maps the closed client-event enum to daemon-owned notice
802 // content. routingMu makes ownership validation and toast mutation one atomic802 // content. routingMu makes ownership validation and toast mutation one atomic
803-// attachment-routing operation: replacement also takes routingMu before803+// attachment-routing operation: attachment publication also takes routingMu
804-// publishing sess.client. Never retain sess.mu while touching notice or overlay804+// before changing membership. Never retain sess.mu while touching notice or overlay
805 // state, and retain the routingMu -> sess.mu order used by attachment paths.805 // state, and retain the routingMu -> sess.mu order used by attachment paths.
806 func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) {806 func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) {
807 if sess == nil || ac == nil {807 if sess == nil || ac == nil {
@@ -846,7 +846,7 @@ func (d *Daemon) handleClientNoticeForRole(token attachmentRoleToken, notice por
846 func (d *Daemon) handleClientNoticeDirect(sess *session, ac *attachedClient, notice ports.ClientNotice) {846 func (d *Daemon) handleClientNoticeDirect(sess *session, ac *attachedClient, notice ports.ClientNotice) {
847 d.notices.routingMu.Lock()847 d.notices.routingMu.Lock()
848 sess.mu.Lock()848 sess.mu.Lock()
849- current := sess.client == ac849+ _, current := sess.attachments[ac]
850 sess.mu.Unlock()850 sess.mu.Unlock()
851 if !current {851 if !current {
852 d.notices.routingMu.Unlock()852 d.notices.routingMu.Unlock()
M internal/usecase/daemon/client_transport_errors.go +1 -7
@@ -64,12 +64,6 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
6464 }
6565 if rc := sess.renderCoordinator(); rc != nil {
6666 rc.noteDetach(ac)
67- sess.mu.Lock()
68- primary := sess.client
69- sess.mu.Unlock()
70- if primary != nil {
71- d.attachCoordinator(sess, ac, primary, true)
72- }
7367 }
7468 sess.mu.Lock()
7569 ephemeral := sess.ephemeral
@@ -150,7 +144,7 @@ func (d *Daemon) detachProxyOnSendError(p *proxySession, ac *attachedClient, fai
150144
151145 // finishProxyClientGone retires external client ownership after the exact proxy
152146 // role has been unpublished. Warm-timer publication remains with the caller so
153-// each detach path can arm once, after p.client is nil.
147+// each detach path can arm once, after membership is empty.
154148 func (d *Daemon) finishProxyClientGone(p *proxySession, ac *attachedClient, failed ports.Transport, explicit bool) {
155149 if rc := p.coordinator.Load(); rc != nil {
156150 rc.noteDetach(ac)
@@ -64,12 +64,6 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
64 }64 }
65 if rc := sess.renderCoordinator(); rc != nil {65 if rc := sess.renderCoordinator(); rc != nil {
66 rc.noteDetach(ac)66 rc.noteDetach(ac)
67- sess.mu.Lock()
68- primary := sess.client
69- sess.mu.Unlock()
70- if primary != nil {
71- d.attachCoordinator(sess, ac, primary, true)
72- }
73 }67 }
74 sess.mu.Lock()68 sess.mu.Lock()
75 ephemeral := sess.ephemeral69 ephemeral := sess.ephemeral
@@ -150,7 +144,7 @@ func (d *Daemon) detachProxyOnSendError(p *proxySession, ac *attachedClient, fai
150 144
151 // finishProxyClientGone retires external client ownership after the exact proxy145 // finishProxyClientGone retires external client ownership after the exact proxy
152 // role has been unpublished. Warm-timer publication remains with the caller so146 // role has been unpublished. Warm-timer publication remains with the caller so
153-// each detach path can arm once, after p.client is nil.147+// each detach path can arm once, after membership is empty.
154 func (d *Daemon) finishProxyClientGone(p *proxySession, ac *attachedClient, failed ports.Transport, explicit bool) {148 func (d *Daemon) finishProxyClientGone(p *proxySession, ac *attachedClient, failed ports.Transport, explicit bool) {
155 if rc := p.coordinator.Load(); rc != nil {149 if rc := p.coordinator.Load(); rc != nil {
156 rc.noteDetach(ac)150 rc.noteDetach(ac)
M internal/usecase/daemon/clipboard.go +10 -20
@@ -226,32 +226,22 @@ func (d *Daemon) forwardClipboardAsync(owner paneEffectLease, b64 string) {
226226 default:
227227 }
228228 }
229- ac := sess.client
229+ attachments := sess.snapshotAttachmentsLocked()
230230 sess.mu.Unlock()
231- if ac == nil {
232- return
233- }
234- transport := ac.transport()
235- token := sess.attachmentToken(ac, transport)
236- if !token.activeCurrent() {
237- return
238- }
239231
240- sess.mu.Lock()
241- if sess.ctx != nil {
242- select {
243- case <-sess.ctx.Done():
244- sess.mu.Unlock()
245- return
246- default:
232+ items := make([]clipboardForward, 0, len(attachments))
233+ for _, ac := range attachments {
234+ transport := ac.transport()
235+ token := sess.attachmentToken(ac, transport)
236+ if token.activeCurrent() {
237+ items = append(items, clipboardForward{owner: owner, token: token, seq: seq})
247238 }
248239 }
249- if sess.client != ac || ac.roleGeneration.Load() != token.generation ||
250- !ac.transportSnapshotCurrent(token.transport) || ac.currentSession() != sess {
251- sess.mu.Unlock()
240+ if len(items) == 0 {
252241 return
253242 }
254- sess.clipboardQueue = append(sess.clipboardQueue, clipboardForward{owner: owner, token: token, seq: seq})
243+ sess.mu.Lock()
244+ sess.clipboardQueue = append(sess.clipboardQueue, items...)
255245 if !sess.clipboardWorkerRunning {
256246 sess.clipboardWorkerRunning = true
257247 go d.clipboardWorker(sess)
@@ -226,32 +226,22 @@ func (d *Daemon) forwardClipboardAsync(owner paneEffectLease, b64 string) {
226 default:226 default:
227 }227 }
228 }228 }
229- ac := sess.client229+ attachments := sess.snapshotAttachmentsLocked()
230 sess.mu.Unlock()230 sess.mu.Unlock()
231- if ac == nil {
232- return
233- }
234- transport := ac.transport()
235- token := sess.attachmentToken(ac, transport)
236- if !token.activeCurrent() {
237- return
238- }
239 231
240- sess.mu.Lock()232+ items := make([]clipboardForward, 0, len(attachments))
241- if sess.ctx != nil {233+ for _, ac := range attachments {
242- select {234+ transport := ac.transport()
243- case <-sess.ctx.Done():235+ token := sess.attachmentToken(ac, transport)
244- sess.mu.Unlock()236+ if token.activeCurrent() {
245- return237+ items = append(items, clipboardForward{owner: owner, token: token, seq: seq})
246- default:
247 }238 }
248 }239 }
249- if sess.client != ac || ac.roleGeneration.Load() != token.generation ||240+ if len(items) == 0 {
250- !ac.transportSnapshotCurrent(token.transport) || ac.currentSession() != sess {
251- sess.mu.Unlock()
252 return241 return
253 }242 }
254- sess.clipboardQueue = append(sess.clipboardQueue, clipboardForward{owner: owner, token: token, seq: seq})243+ sess.mu.Lock()
244+ sess.clipboardQueue = append(sess.clipboardQueue, items...)
255 if !sess.clipboardWorkerRunning {245 if !sess.clipboardWorkerRunning {
256 sess.clipboardWorkerRunning = true246 sess.clipboardWorkerRunning = true
257 go d.clipboardWorker(sess)247 go d.clipboardWorker(sess)
M internal/usecase/daemon/config.go +9 -9
@@ -345,16 +345,16 @@ func (d *Daemon) reapplyThemeAllSessions() {
345345 }
346346
347347 func (d *Daemon) reapplyThemeSession(sess *session) {
348- sess.mu.Lock()
349- ac := sess.client
350- sess.mu.Unlock()
351- if ac == nil {
348+ attachments := sess.snapshotAttachments()
349+ if len(attachments) == 0 {
352350 d.applyHostTheme(sess, nil, theme.Theme{}, true)
353351 return
354352 }
355- ac.sendMu.Lock()
356- defer ac.sendMu.Unlock()
357- sess.themeMu.Lock()
358- defer sess.themeMu.Unlock()
359- d.applyHostThemeLocked(sess, ac, ac.getClientTheme(), false)
353+ for _, ac := range attachments {
354+ ac.sendMu.Lock()
355+ sess.themeMu.Lock()
356+ d.applyHostThemeLocked(sess, ac, ac.getClientTheme(), false)
357+ sess.themeMu.Unlock()
358+ ac.sendMu.Unlock()
359+ }
360360 }
@@ -345,16 +345,16 @@ func (d *Daemon) reapplyThemeAllSessions() {
345 }345 }
346 346
347 func (d *Daemon) reapplyThemeSession(sess *session) {347 func (d *Daemon) reapplyThemeSession(sess *session) {
348- sess.mu.Lock()348+ attachments := sess.snapshotAttachments()
349- ac := sess.client349+ if len(attachments) == 0 {
350- sess.mu.Unlock()
351- if ac == nil {
352 d.applyHostTheme(sess, nil, theme.Theme{}, true)350 d.applyHostTheme(sess, nil, theme.Theme{}, true)
353 return351 return
354 }352 }
355- ac.sendMu.Lock()353+ for _, ac := range attachments {
356- defer ac.sendMu.Unlock()354+ ac.sendMu.Lock()
357- sess.themeMu.Lock()355+ sess.themeMu.Lock()
358- defer sess.themeMu.Unlock()356+ d.applyHostThemeLocked(sess, ac, ac.getClientTheme(), false)
359- d.applyHostThemeLocked(sess, ac, ac.getClientTheme(), false)357+ sess.themeMu.Unlock()
358+ ac.sendMu.Unlock()
359+ }
360 }360 }
M internal/usecase/daemon/control.go +18 -19
@@ -186,7 +186,6 @@ func (s *session) containsStableIDs(tabID, paneID string) bool {
186186 func resolveControlTarget(sess *session, kind command.TargetKind, request ports.CommandRequest) (*tab, *pane, uint16, string) {
187187 sess.mu.Lock()
188188 tabs := append([]*tab(nil), sess.tabs...)
189- active := sess.active
190189 sess.mu.Unlock()
191190 if request.Self {
192191 for _, tb := range tabs {
@@ -204,10 +203,10 @@ func resolveControlTarget(sess *session, kind command.TargetKind, request ports.
204203 }
205204 return nil, nil, ports.ErrNoSuchTarget, "no such target tab"
206205 }
207- if len(tabs) == 0 || active < 0 || active >= len(tabs) {
208- return nil, nil, ports.ErrNoSuchTarget, "target session has no active tab"
206+ if len(tabs) == 0 {
207+ return nil, nil, ports.ErrNoSuchTarget, "target session has no tabs"
209208 }
210- tb := tabs[active]
209+ tb := tabs[0]
211210 if kind != command.TargetPane {
212211 return tb, nil, 0, ""
213212 }
@@ -249,9 +248,10 @@ const (
249248 )
250249
251250 type daemonActionTarget struct {
252- session *session
253- tab *tab
254- pane *pane
251+ session *session
252+ attachment *attachedClient
253+ tab *tab
254+ pane *pane
255255 }
256256
257257 type daemonActionRequest struct {
@@ -283,7 +283,7 @@ func resolveDaemonActionTargetForAttachment(sess *session, ac *attachedClient) d
283283 tb.mu.Lock()
284284 pane := tb.focusedPane()
285285 tb.mu.Unlock()
286- return daemonActionTarget{session: sess, tab: tb, pane: pane}
286+ return daemonActionTarget{session: sess, attachment: ac, tab: tb, pane: pane}
287287 }
288288
289289 // daemonActions is the daemon-owned mutation seam shared by palette and
@@ -322,9 +322,9 @@ func (a daemonActions) Run(request daemonActionRequest) error {
322322 _, err := a.d.focusDirAt(target.session, target.tab, target.pane, request.direction)
323323 return err
324324 case daemonActionNextTab:
325- return a.switchRelative(target.session, 1)
325+ return a.switchRelative(target.session, target.attachment, 1)
326326 case daemonActionPreviousTab:
327- return a.switchRelative(target.session, -1)
327+ return a.switchRelative(target.session, target.attachment, -1)
328328 case daemonActionRenameSession:
329329 return a.d.renameSession(target.session, request.name)
330330 case daemonActionRenameTab:
@@ -363,9 +363,9 @@ func (d *Daemon) hasDaemonActionPaneTarget(target daemonActionTarget) bool {
363363 target.tab.tree != nil && layout.ContainsLeaf(target.tab.tree.Root, target.pane.id)
364364 }
365365
366-func (a daemonActions) switchRelative(sess *session, delta int) error {
367- if sess.switchRelative(delta) {
368- a.d.activateTab(sess, sess.activeTab())
366+func (a daemonActions) switchRelative(sess *session, ac *attachedClient, delta int) error {
367+ if sess.switchAttachmentRelative(ac, delta) {
368+ a.d.activateTabAfterResizeForLease(sess, sess.tabForAttachmentOrActive(ac), false, ac, nil)
369369 }
370370 return nil
371371 }
@@ -398,10 +398,7 @@ func (e controlExec) runAction(request daemonActionRequest) error {
398398 return err
399399 }
400400 if e.actions == nil {
401- e.sess.mu.Lock()
402- ac := e.sess.client
403- e.sess.mu.Unlock()
404- finishDaemonActionForClient(e.d, request, ac, "control.go")
401+ finishDaemonActionForClient(e.d, request, request.target.attachment, "control.go")
405402 }
406403 return nil
407404 }
@@ -455,6 +452,7 @@ func (e controlExec) MovePane(destinationSession, destinationTabID string) error
455452 return errMovePaneInvalid
456453 }
457454 return e.d.movePane(movePaneRequest{
455+ Client: e.target.attachment,
458456 Source: sessionMoveLocator(e.sess),
459457 SourceTabID: domain.TabStableID(e.tab.stableID),
460458 SourcePaneID: domain.PaneStableID(e.target.pane.stableID),
@@ -472,6 +470,7 @@ func (e controlExec) MoveTab(destinationSession string) error {
472470 return errMovePaneInvalid
473471 }
474472 return e.d.moveTab(moveTabRequest{
473+ Client: e.target.attachment,
475474 Source: sessionMoveLocator(e.sess),
476475 SourceTabID: domain.TabStableID(e.tab.stableID),
477476 Destination: sessionMoveLocator(destination),
@@ -661,8 +660,8 @@ func (e controlExec) ListTabs(asJSON bool) (string, error) {
661660 }
662661 e.sess.mu.Lock()
663662 tabs := append([]*tab(nil), e.sess.tabs...)
664- active := e.sess.active
665663 e.sess.mu.Unlock()
664+ active, _ := e.sess.tabIndexForAttachment(e.target.attachment)
666665 rows := make([]row, 0, len(tabs))
667666 for i, tb := range tabs {
668667 tb.mu.Lock()
@@ -690,7 +689,7 @@ func (e controlExec) ListPanes(asJSON bool) (string, error) {
690689 }
691690 tb := e.tab
692691 if tb == nil {
693- tb = e.sess.activeTab()
692+ tb = e.sess.tabForAttachmentOrActive(e.target.attachment)
694693 }
695694 if tb == nil {
696695 return "", errors.New("target session has no active tab")
@@ -186,7 +186,6 @@ func (s *session) containsStableIDs(tabID, paneID string) bool {
186 func resolveControlTarget(sess *session, kind command.TargetKind, request ports.CommandRequest) (*tab, *pane, uint16, string) {186 func resolveControlTarget(sess *session, kind command.TargetKind, request ports.CommandRequest) (*tab, *pane, uint16, string) {
187 sess.mu.Lock()187 sess.mu.Lock()
188 tabs := append([]*tab(nil), sess.tabs...)188 tabs := append([]*tab(nil), sess.tabs...)
189- active := sess.active
190 sess.mu.Unlock()189 sess.mu.Unlock()
191 if request.Self {190 if request.Self {
192 for _, tb := range tabs {191 for _, tb := range tabs {
@@ -204,10 +203,10 @@ func resolveControlTarget(sess *session, kind command.TargetKind, request ports.
204 }203 }
205 return nil, nil, ports.ErrNoSuchTarget, "no such target tab"204 return nil, nil, ports.ErrNoSuchTarget, "no such target tab"
206 }205 }
207- if len(tabs) == 0 || active < 0 || active >= len(tabs) {206+ if len(tabs) == 0 {
208- return nil, nil, ports.ErrNoSuchTarget, "target session has no active tab"207+ return nil, nil, ports.ErrNoSuchTarget, "target session has no tabs"
209 }208 }
210- tb := tabs[active]209+ tb := tabs[0]
211 if kind != command.TargetPane {210 if kind != command.TargetPane {
212 return tb, nil, 0, ""211 return tb, nil, 0, ""
213 }212 }
@@ -249,9 +248,10 @@ const (
249 )248 )
250 249
251 type daemonActionTarget struct {250 type daemonActionTarget struct {
252- session *session251+ session *session
253- tab *tab252+ attachment *attachedClient
254- pane *pane253+ tab *tab
254+ pane *pane
255 }255 }
256 256
257 type daemonActionRequest struct {257 type daemonActionRequest struct {
@@ -283,7 +283,7 @@ func resolveDaemonActionTargetForAttachment(sess *session, ac *attachedClient) d
283 tb.mu.Lock()283 tb.mu.Lock()
284 pane := tb.focusedPane()284 pane := tb.focusedPane()
285 tb.mu.Unlock()285 tb.mu.Unlock()
286- return daemonActionTarget{session: sess, tab: tb, pane: pane}286+ return daemonActionTarget{session: sess, attachment: ac, tab: tb, pane: pane}
287 }287 }
288 288
289 // daemonActions is the daemon-owned mutation seam shared by palette and289 // daemonActions is the daemon-owned mutation seam shared by palette and
@@ -322,9 +322,9 @@ func (a daemonActions) Run(request daemonActionRequest) error {
322 _, err := a.d.focusDirAt(target.session, target.tab, target.pane, request.direction)322 _, err := a.d.focusDirAt(target.session, target.tab, target.pane, request.direction)
323 return err323 return err
324 case daemonActionNextTab:324 case daemonActionNextTab:
325- return a.switchRelative(target.session, 1)325+ return a.switchRelative(target.session, target.attachment, 1)
326 case daemonActionPreviousTab:326 case daemonActionPreviousTab:
327- return a.switchRelative(target.session, -1)327+ return a.switchRelative(target.session, target.attachment, -1)
328 case daemonActionRenameSession:328 case daemonActionRenameSession:
329 return a.d.renameSession(target.session, request.name)329 return a.d.renameSession(target.session, request.name)
330 case daemonActionRenameTab:330 case daemonActionRenameTab:
@@ -363,9 +363,9 @@ func (d *Daemon) hasDaemonActionPaneTarget(target daemonActionTarget) bool {
363 target.tab.tree != nil && layout.ContainsLeaf(target.tab.tree.Root, target.pane.id)363 target.tab.tree != nil && layout.ContainsLeaf(target.tab.tree.Root, target.pane.id)
364 }364 }
365 365
366-func (a daemonActions) switchRelative(sess *session, delta int) error {366+func (a daemonActions) switchRelative(sess *session, ac *attachedClient, delta int) error {
367- if sess.switchRelative(delta) {367+ if sess.switchAttachmentRelative(ac, delta) {
368- a.d.activateTab(sess, sess.activeTab())368+ a.d.activateTabAfterResizeForLease(sess, sess.tabForAttachmentOrActive(ac), false, ac, nil)
369 }369 }
370 return nil370 return nil
371 }371 }
@@ -398,10 +398,7 @@ func (e controlExec) runAction(request daemonActionRequest) error {
398 return err398 return err
399 }399 }
400 if e.actions == nil {400 if e.actions == nil {
401- e.sess.mu.Lock()401+ finishDaemonActionForClient(e.d, request, request.target.attachment, "control.go")
402- ac := e.sess.client
403- e.sess.mu.Unlock()
404- finishDaemonActionForClient(e.d, request, ac, "control.go")
405 }402 }
406 return nil403 return nil
407 }404 }
@@ -455,6 +452,7 @@ func (e controlExec) MovePane(destinationSession, destinationTabID string) error
455 return errMovePaneInvalid452 return errMovePaneInvalid
456 }453 }
457 return e.d.movePane(movePaneRequest{454 return e.d.movePane(movePaneRequest{
455+ Client: e.target.attachment,
458 Source: sessionMoveLocator(e.sess),456 Source: sessionMoveLocator(e.sess),
459 SourceTabID: domain.TabStableID(e.tab.stableID),457 SourceTabID: domain.TabStableID(e.tab.stableID),
460 SourcePaneID: domain.PaneStableID(e.target.pane.stableID),458 SourcePaneID: domain.PaneStableID(e.target.pane.stableID),
@@ -472,6 +470,7 @@ func (e controlExec) MoveTab(destinationSession string) error {
472 return errMovePaneInvalid470 return errMovePaneInvalid
473 }471 }
474 return e.d.moveTab(moveTabRequest{472 return e.d.moveTab(moveTabRequest{
473+ Client: e.target.attachment,
475 Source: sessionMoveLocator(e.sess),474 Source: sessionMoveLocator(e.sess),
476 SourceTabID: domain.TabStableID(e.tab.stableID),475 SourceTabID: domain.TabStableID(e.tab.stableID),
477 Destination: sessionMoveLocator(destination),476 Destination: sessionMoveLocator(destination),
@@ -661,8 +660,8 @@ func (e controlExec) ListTabs(asJSON bool) (string, error) {
661 }660 }
662 e.sess.mu.Lock()661 e.sess.mu.Lock()
663 tabs := append([]*tab(nil), e.sess.tabs...)662 tabs := append([]*tab(nil), e.sess.tabs...)
664- active := e.sess.active
665 e.sess.mu.Unlock()663 e.sess.mu.Unlock()
664+ active, _ := e.sess.tabIndexForAttachment(e.target.attachment)
666 rows := make([]row, 0, len(tabs))665 rows := make([]row, 0, len(tabs))
667 for i, tb := range tabs {666 for i, tb := range tabs {
668 tb.mu.Lock()667 tb.mu.Lock()
@@ -690,7 +689,7 @@ func (e controlExec) ListPanes(asJSON bool) (string, error) {
690 }689 }
691 tb := e.tab690 tb := e.tab
692 if tb == nil {691 if tb == nil {
693- tb = e.sess.activeTab()692+ tb = e.sess.tabForAttachmentOrActive(e.target.attachment)
694 }693 }
695 if tb == nil {694 if tb == nil {
696 return "", errors.New("target session has no active tab")695 return "", errors.New("target session has no active tab")
M internal/usecase/daemon/daemon.go +1 -1
@@ -992,7 +992,7 @@ func (d *Daemon) handleList(tr ports.Transport) {
992992 State: ports.SessionRunning,
993993 Ephemeral: s.ephemeral,
994994 Tabs: uint16(len(s.tabs)),
995- Attached: s.client != nil,
995+ Attached: len(s.attachments) != 0,
996996 }
997997 liveNames[s.name] = struct{}{}
998998 s.mu.Unlock()
@@ -992,7 +992,7 @@ func (d *Daemon) handleList(tr ports.Transport) {
992 State: ports.SessionRunning,992 State: ports.SessionRunning,
993 Ephemeral: s.ephemeral,993 Ephemeral: s.ephemeral,
994 Tabs: uint16(len(s.tabs)),994 Tabs: uint16(len(s.tabs)),
995- Attached: s.client != nil,995+ Attached: len(s.attachments) != 0,
996 }996 }
997 liveNames[s.name] = struct{}{}997 liveNames[s.name] = struct{}{}
998 s.mu.Unlock()998 s.mu.Unlock()
M internal/usecase/daemon/daemon_test.go +1 -1
@@ -413,7 +413,7 @@ func newManualSessionWithPTYsClockCleanup(t testing.TB, clock ports.Clock, regis
413413 }
414414 tabs = append(tabs, tb)
415415 }
416- sess := &session{sessionCore: sessionCore{id: "manual", name: "work", client: ac}, ctx: sctx, cancel: cancel, tabs: tabs}
416+ sess := &session{sessionCore: sessionCore{id: "manual", name: "work", client: ac, attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx, cancel: cancel, tabs: tabs}
417417 for _, tb := range tabs {
418418 publishTiledPaneOwners(sess, tb)
419419 }
@@ -413,7 +413,7 @@ func newManualSessionWithPTYsClockCleanup(t testing.TB, clock ports.Clock, regis
413 }413 }
414 tabs = append(tabs, tb)414 tabs = append(tabs, tb)
415 }415 }
416- sess := &session{sessionCore: sessionCore{id: "manual", name: "work", client: ac}, ctx: sctx, cancel: cancel, tabs: tabs}416+ sess := &session{sessionCore: sessionCore{id: "manual", name: "work", client: ac, attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx, cancel: cancel, tabs: tabs}
417 for _, tb := range tabs {417 for _, tb := range tabs {
418 publishTiledPaneOwners(sess, tb)418 publishTiledPaneOwners(sess, tb)
419 }419 }
M internal/usecase/daemon/floating.go +15 -15
@@ -182,9 +182,10 @@ func (d *Daemon) activateTabAfterResizeForLease(sess *session, tb *tab, outerRes
182182 // until firstPaint keeps restored tabs cold and avoids launching children
183183 // merely because a tab was manipulated during teardown.
184184 if ac == nil {
185- sess.mu.Lock()
186- ac = sess.client
187- sess.mu.Unlock()
185+ attachments := sess.snapshotAttachments()
186+ if len(attachments) != 0 {
187+ ac = attachments[0]
188+ }
188189 }
189190 if rc := sess.renderCoordinator(); rc != nil && rc.opts.onActivateTabAfterResize != nil {
190191 rc.opts.onActivateTabAfterResize(lease != nil)
@@ -478,13 +479,13 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin
478479 d.reapplyThemeSession(sess)
479480 d.startPaneGoroutines(sess, tb, p)
480481 if visible {
481- sess.mu.Lock()
482- ac := sess.client
483- sess.mu.Unlock()
482+ attachments := sess.snapshotAttachments()
484483 tb.mu.Lock()
485484 size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
486485 tb.mu.Unlock()
487- d.requestTransactionalResize(sess, ac, size, true)
486+ for _, ac := range attachments {
487+ d.requestTransactionalResize(sess, ac, size, true)
488+ }
488489 }
489490 }
490491
@@ -515,15 +516,14 @@ func (d *Daemon) reapInstalledFloating(p *pane) {
515516 return
516517 }
517518 closeFloatingPane(p)
518- sess.mu.Lock()
519- ac := sess.client
520- sess.mu.Unlock()
521- if ac != nil {
519+ attachments := sess.snapshotAttachments()
520+ copyCleared := false
521+ for _, ac := range attachments {
522522 ac.pruneCaptureFrames(p)
523- }
524- copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p)
525- if ac != nil && (visible || copyCleared) {
526- d.invalidateRender(sess, ac, true, "floating.go")
523+ copyCleared = ac.overlays.clearCopyModeForPane(p) || copyCleared
524+ if visible || copyCleared {
525+ d.invalidateRender(sess, ac, true, "floating.go")
526+ }
527527 }
528528 return
529529 }
@@ -182,9 +182,10 @@ func (d *Daemon) activateTabAfterResizeForLease(sess *session, tb *tab, outerRes
182 // until firstPaint keeps restored tabs cold and avoids launching children182 // until firstPaint keeps restored tabs cold and avoids launching children
183 // merely because a tab was manipulated during teardown.183 // merely because a tab was manipulated during teardown.
184 if ac == nil {184 if ac == nil {
185- sess.mu.Lock()185+ attachments := sess.snapshotAttachments()
186- ac = sess.client186+ if len(attachments) != 0 {
187- sess.mu.Unlock()187+ ac = attachments[0]
188+ }
188 }189 }
189 if rc := sess.renderCoordinator(); rc != nil && rc.opts.onActivateTabAfterResize != nil {190 if rc := sess.renderCoordinator(); rc != nil && rc.opts.onActivateTabAfterResize != nil {
190 rc.opts.onActivateTabAfterResize(lease != nil)191 rc.opts.onActivateTabAfterResize(lease != nil)
@@ -478,13 +479,13 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin
478 d.reapplyThemeSession(sess)479 d.reapplyThemeSession(sess)
479 d.startPaneGoroutines(sess, tb, p)480 d.startPaneGoroutines(sess, tb, p)
480 if visible {481 if visible {
481- sess.mu.Lock()482+ attachments := sess.snapshotAttachments()
482- ac := sess.client
483- sess.mu.Unlock()
484 tb.mu.Lock()483 tb.mu.Lock()
485 size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}484 size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
486 tb.mu.Unlock()485 tb.mu.Unlock()
487- d.requestTransactionalResize(sess, ac, size, true)486+ for _, ac := range attachments {
487+ d.requestTransactionalResize(sess, ac, size, true)
488+ }
488 }489 }
489 }490 }
490 491
@@ -515,15 +516,14 @@ func (d *Daemon) reapInstalledFloating(p *pane) {
515 return516 return
516 }517 }
517 closeFloatingPane(p)518 closeFloatingPane(p)
518- sess.mu.Lock()519+ attachments := sess.snapshotAttachments()
519- ac := sess.client520+ copyCleared := false
520- sess.mu.Unlock()521+ for _, ac := range attachments {
521- if ac != nil {
522 ac.pruneCaptureFrames(p)522 ac.pruneCaptureFrames(p)
523- }523+ copyCleared = ac.overlays.clearCopyModeForPane(p) || copyCleared
524- copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p)524+ if visible || copyCleared {
525- if ac != nil && (visible || copyCleared) {525+ d.invalidateRender(sess, ac, true, "floating.go")
526- d.invalidateRender(sess, ac, true, "floating.go")526+ }
527 }527 }
528 return528 return
529 }529 }
M internal/usecase/daemon/move.go +2 -0
@@ -10,6 +10,7 @@ import (
1010 // immutable identity. Session names are advisory; the ID and incarnation are
1111 // the commit-time authority.
1212 type movePaneRequest struct {
13+ Client *attachedClient
1314 Source moveSessionLocator
1415 SourceTabID domain.TabStableID
1516 SourcePaneID domain.PaneStableID
@@ -18,6 +19,7 @@ type movePaneRequest struct {
1819 }
1920
2021 type moveTabRequest struct {
22+ Client *attachedClient
2123 Source moveSessionLocator
2224 SourceTabID domain.TabStableID
2325 Destination moveSessionLocator
@@ -10,6 +10,7 @@ import (
10 // immutable identity. Session names are advisory; the ID and incarnation are10 // immutable identity. Session names are advisory; the ID and incarnation are
11 // the commit-time authority.11 // the commit-time authority.
12 type movePaneRequest struct {12 type movePaneRequest struct {
13+ Client *attachedClient
13 Source moveSessionLocator14 Source moveSessionLocator
14 SourceTabID domain.TabStableID15 SourceTabID domain.TabStableID
15 SourcePaneID domain.PaneStableID16 SourcePaneID domain.PaneStableID
@@ -18,6 +19,7 @@ type movePaneRequest struct {
18 }19 }
19 20
20 type moveTabRequest struct {21 type moveTabRequest struct {
22+ Client *attachedClient
21 Source moveSessionLocator23 Source moveSessionLocator
22 SourceTabID domain.TabStableID24 SourceTabID domain.TabStableID
23 Destination moveSessionLocator25 Destination moveSessionLocator
M internal/usecase/daemon/move_attachments.go +1 -17
@@ -7,24 +7,8 @@ type frozenMoveAttachmentRetirement struct {
77 clients []*attachedClient
88 }
99
10-func snapshotMoveSnatchedLocked(sess *session) []*attachedClient {
11- clients := make([]*attachedClient, 0, len(sess.snatched))
12- for ac := range sess.snatched {
13- clients = append(clients, ac)
14- }
15- return clients
16-}
17-
1810 func sameMoveSnatchedLocked(sess *session, admitted []*attachedClient) bool {
19- if sess == nil || len(sess.snatched) != len(admitted) {
20- return false
21- }
22- for _, ac := range admitted {
23- if _, ok := sess.snatched[ac]; !ok {
24- return false
25- }
26- }
27- return true
11+ return sess != nil && len(admitted) == 0
2812 }
2913
3014 func prepareFrozenMoveAttachmentRetirementLocked(sess *session, admitted []*attachedClient, frozen frozenRoleEffectGates) (frozenMoveAttachmentRetirement, bool) {
@@ -7,24 +7,8 @@ type frozenMoveAttachmentRetirement struct {
7 clients []*attachedClient7 clients []*attachedClient
8 }8 }
9 9
10-func snapshotMoveSnatchedLocked(sess *session) []*attachedClient {
11- clients := make([]*attachedClient, 0, len(sess.snatched))
12- for ac := range sess.snatched {
13- clients = append(clients, ac)
14- }
15- return clients
16-}
17-
18 func sameMoveSnatchedLocked(sess *session, admitted []*attachedClient) bool {10 func sameMoveSnatchedLocked(sess *session, admitted []*attachedClient) bool {
19- if sess == nil || len(sess.snatched) != len(admitted) {11+ return sess != nil && len(admitted) == 0
20- return false
21- }
22- for _, ac := range admitted {
23- if _, ok := sess.snatched[ac]; !ok {
24- return false
25- }
26- }
27- return true
28 }12 }
29 13
30 func prepareFrozenMoveAttachmentRetirementLocked(sess *session, admitted []*attachedClient, frozen frozenRoleEffectGates) (frozenMoveAttachmentRetirement, bool) {14 func prepareFrozenMoveAttachmentRetirementLocked(sess *session, admitted []*attachedClient, frozen frozenRoleEffectGates) (frozenMoveAttachmentRetirement, bool) {
M internal/usecase/daemon/move_pane_admission.go +10 -4
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "github.com/bnema/vev/internal/domain"
45 "github.com/bnema/vev/internal/usecase/layout"
56 )
67
@@ -56,19 +57,24 @@ func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destinat
5657 return nil, errMoveStaleTarget
5758 }
5859
60+ sourceTabWasActive := false
61+ if req.Client != nil {
62+ view := req.Client.viewSnapshot()
63+ sourceTabWasActive = view.tabID == domain.TabStableID(sourceTab.stableID)
64+ }
5965 return &movePaneAdmission{
6066 source: source,
6167 destination: destination,
6268 sourceTab: sourceTab,
6369 destinationTab: destinationTab,
6470 movedPane: movedPane,
65- sourceClient: source.client,
66- destinationClient: destination.client,
67- sourceSnatched: snapshotMoveSnatchedLocked(source),
71+ sourceClient: req.Client,
72+ destinationClient: nil,
73+ sourceSnatched: nil,
6874 sourceGeneration: sourceTab.layoutGeneration,
6975 destinationGeneration: destinationTab.layoutGeneration,
7076 finalSourceTab: len(source.tabs) == 1 && source.tabs[0] == sourceTab &&
7177 sourceTab.tree != nil && sourceTab.tree.Root != nil && len(layout.LeafIDs(sourceTab.tree.Root)) == 1,
72- sourceTabWasActive: source.active >= 0 && source.active < len(source.tabs) && source.tabs[source.active] == sourceTab,
78+ sourceTabWasActive: sourceTabWasActive,
7379 }, nil
7480 }
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "github.com/bnema/vev/internal/domain"
4 "github.com/bnema/vev/internal/usecase/layout"5 "github.com/bnema/vev/internal/usecase/layout"
5 )6 )
6 7
@@ -56,19 +57,24 @@ func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destinat
56 return nil, errMoveStaleTarget57 return nil, errMoveStaleTarget
57 }58 }
58 59
60+ sourceTabWasActive := false
61+ if req.Client != nil {
62+ view := req.Client.viewSnapshot()
63+ sourceTabWasActive = view.tabID == domain.TabStableID(sourceTab.stableID)
64+ }
59 return &movePaneAdmission{65 return &movePaneAdmission{
60 source: source,66 source: source,
61 destination: destination,67 destination: destination,
62 sourceTab: sourceTab,68 sourceTab: sourceTab,
63 destinationTab: destinationTab,69 destinationTab: destinationTab,
64 movedPane: movedPane,70 movedPane: movedPane,
65- sourceClient: source.client,71+ sourceClient: req.Client,
66- destinationClient: destination.client,72+ destinationClient: nil,
67- sourceSnatched: snapshotMoveSnatchedLocked(source),73+ sourceSnatched: nil,
68 sourceGeneration: sourceTab.layoutGeneration,74 sourceGeneration: sourceTab.layoutGeneration,
69 destinationGeneration: destinationTab.layoutGeneration,75 destinationGeneration: destinationTab.layoutGeneration,
70 finalSourceTab: len(source.tabs) == 1 && source.tabs[0] == sourceTab &&76 finalSourceTab: len(source.tabs) == 1 && source.tabs[0] == sourceTab &&
71 sourceTab.tree != nil && sourceTab.tree.Root != nil && len(layout.LeafIDs(sourceTab.tree.Root)) == 1,77 sourceTab.tree != nil && sourceTab.tree.Root != nil && len(layout.LeafIDs(sourceTab.tree.Root)) == 1,
72- sourceTabWasActive: source.active >= 0 && source.active < len(source.tabs) && source.tabs[source.active] == sourceTab,78+ sourceTabWasActive: sourceTabWasActive,
73 }, nil79 }, nil
74 }80 }
M internal/usecase/daemon/move_pane_commit.go +5 -11
@@ -62,7 +62,7 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
6262 return false
6363 }
6464 if c.handoffFrozen {
65- if c.source.client != c.sourceClient || !sameMoveSnatchedLocked(c.source, c.sourceSnatched) ||
65+ if (c.sourceClient != nil && !attachmentRegisteredLocked(c.source, c.sourceClient)) || !sameMoveSnatchedLocked(c.source, c.sourceSnatched) ||
6666 c.handoffReq.targetTabIndex < 0 || c.handoffReq.targetTabIndex >= len(c.destination.tabs) ||
6767 c.destination.tabs[c.handoffReq.targetTabIndex] != c.destinationTab {
6868 return false
@@ -102,7 +102,7 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
102102
103103 sourceWillEmpty := candidate.removeSourceTab && len(c.source.tabs) == 1
104104 if sourceWillEmpty && c.source != c.destination {
105- if c.source.client != c.sourceClient || !sameMoveSnatchedLocked(c.source, c.sourceSnatched) ||
105+ if (c.sourceClient != nil && !attachmentRegisteredLocked(c.source, c.sourceClient)) || !sameMoveSnatchedLocked(c.source, c.sourceSnatched) ||
106106 (c.sourceClient != nil) != c.handoffFrozen {
107107 return false
108108 }
@@ -122,13 +122,7 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
122122 }
123123 c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...)
124124 c.sourceTabRemoved = true
125- if len(c.source.tabs) == 0 {
126- c.source.active = -1
127- } else if c.source.active > idx {
128- c.source.active--
129- } else if c.source.active >= len(c.source.tabs) {
130- c.source.active = len(c.source.tabs) - 1
131- }
125+
132126 } else {
133127 c.sourceTab.tree = candidate.sourceTree
134128 c.sourceTab.bumpLayoutGenerationLocked()
@@ -151,8 +145,8 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
151145 c.syncCleanup.append(d.migratePaneSyncOwnerLocked(c.movedPane, oldOwner, newOwner))
152146 if c.handoffResult.published.ac != nil {
153147 c.sourceCleanupToken = c.handoffResult.published
154- } else if c.source.client != nil {
155- c.sourceCleanupToken = c.source.attachmentTokenLocked(c.source.client)
148+ } else if c.sourceClient != nil {
149+ c.sourceCleanupToken = c.source.attachmentTokenLocked(c.sourceClient)
156150 }
157151
158152 if candidate.removeSourceTab && len(c.source.tabs) == 0 {
@@ -62,7 +62,7 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
62 return false62 return false
63 }63 }
64 if c.handoffFrozen {64 if c.handoffFrozen {
65- if c.source.client != c.sourceClient || !sameMoveSnatchedLocked(c.source, c.sourceSnatched) ||65+ if (c.sourceClient != nil && !attachmentRegisteredLocked(c.source, c.sourceClient)) || !sameMoveSnatchedLocked(c.source, c.sourceSnatched) ||
66 c.handoffReq.targetTabIndex < 0 || c.handoffReq.targetTabIndex >= len(c.destination.tabs) ||66 c.handoffReq.targetTabIndex < 0 || c.handoffReq.targetTabIndex >= len(c.destination.tabs) ||
67 c.destination.tabs[c.handoffReq.targetTabIndex] != c.destinationTab {67 c.destination.tabs[c.handoffReq.targetTabIndex] != c.destinationTab {
68 return false68 return false
@@ -102,7 +102,7 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
102 102
103 sourceWillEmpty := candidate.removeSourceTab && len(c.source.tabs) == 1103 sourceWillEmpty := candidate.removeSourceTab && len(c.source.tabs) == 1
104 if sourceWillEmpty && c.source != c.destination {104 if sourceWillEmpty && c.source != c.destination {
105- if c.source.client != c.sourceClient || !sameMoveSnatchedLocked(c.source, c.sourceSnatched) ||105+ if (c.sourceClient != nil && !attachmentRegisteredLocked(c.source, c.sourceClient)) || !sameMoveSnatchedLocked(c.source, c.sourceSnatched) ||
106 (c.sourceClient != nil) != c.handoffFrozen {106 (c.sourceClient != nil) != c.handoffFrozen {
107 return false107 return false
108 }108 }
@@ -122,13 +122,7 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
122 }122 }
123 c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...)123 c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...)
124 c.sourceTabRemoved = true124 c.sourceTabRemoved = true
125- if len(c.source.tabs) == 0 {125+
126- c.source.active = -1
127- } else if c.source.active > idx {
128- c.source.active--
129- } else if c.source.active >= len(c.source.tabs) {
130- c.source.active = len(c.source.tabs) - 1
131- }
132 } else {126 } else {
133 c.sourceTab.tree = candidate.sourceTree127 c.sourceTab.tree = candidate.sourceTree
134 c.sourceTab.bumpLayoutGenerationLocked()128 c.sourceTab.bumpLayoutGenerationLocked()
@@ -151,8 +145,8 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
151 c.syncCleanup.append(d.migratePaneSyncOwnerLocked(c.movedPane, oldOwner, newOwner))145 c.syncCleanup.append(d.migratePaneSyncOwnerLocked(c.movedPane, oldOwner, newOwner))
152 if c.handoffResult.published.ac != nil {146 if c.handoffResult.published.ac != nil {
153 c.sourceCleanupToken = c.handoffResult.published147 c.sourceCleanupToken = c.handoffResult.published
154- } else if c.source.client != nil {148+ } else if c.sourceClient != nil {
155- c.sourceCleanupToken = c.source.attachmentTokenLocked(c.source.client)149+ c.sourceCleanupToken = c.source.attachmentTokenLocked(c.sourceClient)
156 }150 }
157 151
158 if candidate.removeSourceTab && len(c.source.tabs) == 0 {152 if candidate.removeSourceTab && len(c.source.tabs) == 0 {
M internal/usecase/daemon/move_pane_postcommit.go +13 -19
@@ -84,13 +84,17 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
8484 p.unlockDispatch()
8585 p.reservation.Release()
8686
87- if p.sourceTabWasActive && !moveSessionRetired(d, p.source) {
88- d.activateTab(p.source, p.source.activeTab())
87+ // Repair attachment-local stable targets after shared topology publication
88+ // and after the move dispatch locks have been released.
89+ p.source.repairAttachmentViews()
90+ if p.destination != p.source {
91+ p.destination.repairAttachmentViews()
8992 }
93+
9094 if !moveSessionRetired(d, p.source) {
9195 sourceLayoutTab := p.sourceTab
9296 if p.sourceTabRemoved {
93- sourceLayoutTab = p.source.activeTab()
97+ sourceLayoutTab = p.source.tabForAttachmentOrActive(nil)
9498 }
9599 if sourceLayoutTab != nil && sourceLayoutTab != p.destinationTab {
96100 d.applyTabLayout(p.source, sourceLayoutTab)
@@ -103,10 +107,10 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
103107 if p.destinationMetadataValid {
104108 d.markCatalogueDirty(p.destinationMetadata)
105109 }
106- if p.destinationClient != nil && sessionClientIs(p.destination, p.destinationClient) && destinationTabIsActive(p.destination, p.destinationTab) {
107- d.invalidateRender(p.destination, p.destinationClient, true, "move.go")
110+ for _, ac := range p.destination.snapshotAttachments() {
111+ d.invalidateRender(p.destination, ac, true, "move.go")
108112 }
109- if p.sourceCleanupToken.ac != nil && !p.sourceEmpty && p.sourceTabWasActive && sessionClientIs(p.source, p.sourceCleanupToken.ac) {
113+ if p.sourceCleanupToken.ac != nil && !p.sourceEmpty && sessionClientIs(p.source, p.sourceCleanupToken.ac) {
110114 d.invalidateRender(p.source, p.sourceCleanupToken.ac, true, "move.go")
111115 }
112116 if p.handoffResult.published.ac != nil {
@@ -146,20 +150,18 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
146150 // were not transferred. It runs inside the move commit, after the moved pane
147151 // has already acquired its destination owner, and performs no external work.
148152 func retireEmptyMoveSessionLocked(sess *session, retirement frozenMoveAttachmentRetirement) []detachedAttachmentSnapshot {
149- if sess == nil || sess.client != nil {
153+ if sess == nil || len(sess.attachments) != 0 {
150154 return nil
151155 }
152156 retired := make([]detachedAttachmentSnapshot, 0, len(retirement.clients))
153157 for _, ac := range retirement.clients {
154158 retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: ac.transportSnapshot()})
155- delete(sess.snatched, ac)
156159 sess.unregisterAttachmentLocked(ac)
157160 ac.roleGeneration.Add(1)
158161 ac.setSession(nil)
159162 ac.invalidateFrozenRoleCapability()
160163 }
161164 sess.tabs = nil
162- sess.active = -1
163165 return retired
164166 }
165167
@@ -173,22 +175,14 @@ func moveSessionRetired(d *Daemon, sess *session) bool {
173175 return !live
174176 }
175177
176-func destinationTabIsActive(sess *session, target *tab) bool {
177- if sess == nil || target == nil {
178- return false
179- }
180- sess.mu.Lock()
181- defer sess.mu.Unlock()
182- return sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == target
183-}
184-
185178 func sessionClientIs(sess *session, client *attachedClient) bool {
186179 if sess == nil || client == nil {
187180 return false
188181 }
189182 sess.mu.Lock()
190183 defer sess.mu.Unlock()
191- return sess.client == client
184+ _, ok := sess.attachments[client]
185+ return ok
192186 }
193187
194188 // retireEmptySessionAfterMove completes only source-owned lifecycle work. The
@@ -84,13 +84,17 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
84 p.unlockDispatch()84 p.unlockDispatch()
85 p.reservation.Release()85 p.reservation.Release()
86 86
87- if p.sourceTabWasActive && !moveSessionRetired(d, p.source) {87+ // Repair attachment-local stable targets after shared topology publication
88- d.activateTab(p.source, p.source.activeTab())88+ // and after the move dispatch locks have been released.
89+ p.source.repairAttachmentViews()
90+ if p.destination != p.source {
91+ p.destination.repairAttachmentViews()
89 }92 }
93+
90 if !moveSessionRetired(d, p.source) {94 if !moveSessionRetired(d, p.source) {
91 sourceLayoutTab := p.sourceTab95 sourceLayoutTab := p.sourceTab
92 if p.sourceTabRemoved {96 if p.sourceTabRemoved {
93- sourceLayoutTab = p.source.activeTab()97+ sourceLayoutTab = p.source.tabForAttachmentOrActive(nil)
94 }98 }
95 if sourceLayoutTab != nil && sourceLayoutTab != p.destinationTab {99 if sourceLayoutTab != nil && sourceLayoutTab != p.destinationTab {
96 d.applyTabLayout(p.source, sourceLayoutTab)100 d.applyTabLayout(p.source, sourceLayoutTab)
@@ -103,10 +107,10 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
103 if p.destinationMetadataValid {107 if p.destinationMetadataValid {
104 d.markCatalogueDirty(p.destinationMetadata)108 d.markCatalogueDirty(p.destinationMetadata)
105 }109 }
106- if p.destinationClient != nil && sessionClientIs(p.destination, p.destinationClient) && destinationTabIsActive(p.destination, p.destinationTab) {110+ for _, ac := range p.destination.snapshotAttachments() {
107- d.invalidateRender(p.destination, p.destinationClient, true, "move.go")111+ d.invalidateRender(p.destination, ac, true, "move.go")
108 }112 }
109- if p.sourceCleanupToken.ac != nil && !p.sourceEmpty && p.sourceTabWasActive && sessionClientIs(p.source, p.sourceCleanupToken.ac) {113+ if p.sourceCleanupToken.ac != nil && !p.sourceEmpty && sessionClientIs(p.source, p.sourceCleanupToken.ac) {
110 d.invalidateRender(p.source, p.sourceCleanupToken.ac, true, "move.go")114 d.invalidateRender(p.source, p.sourceCleanupToken.ac, true, "move.go")
111 }115 }
112 if p.handoffResult.published.ac != nil {116 if p.handoffResult.published.ac != nil {
@@ -146,20 +150,18 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
146 // were not transferred. It runs inside the move commit, after the moved pane150 // were not transferred. It runs inside the move commit, after the moved pane
147 // has already acquired its destination owner, and performs no external work.151 // has already acquired its destination owner, and performs no external work.
148 func retireEmptyMoveSessionLocked(sess *session, retirement frozenMoveAttachmentRetirement) []detachedAttachmentSnapshot {152 func retireEmptyMoveSessionLocked(sess *session, retirement frozenMoveAttachmentRetirement) []detachedAttachmentSnapshot {
149- if sess == nil || sess.client != nil {153+ if sess == nil || len(sess.attachments) != 0 {
150 return nil154 return nil
151 }155 }
152 retired := make([]detachedAttachmentSnapshot, 0, len(retirement.clients))156 retired := make([]detachedAttachmentSnapshot, 0, len(retirement.clients))
153 for _, ac := range retirement.clients {157 for _, ac := range retirement.clients {
154 retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: ac.transportSnapshot()})158 retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: ac.transportSnapshot()})
155- delete(sess.snatched, ac)
156 sess.unregisterAttachmentLocked(ac)159 sess.unregisterAttachmentLocked(ac)
157 ac.roleGeneration.Add(1)160 ac.roleGeneration.Add(1)
158 ac.setSession(nil)161 ac.setSession(nil)
159 ac.invalidateFrozenRoleCapability()162 ac.invalidateFrozenRoleCapability()
160 }163 }
161 sess.tabs = nil164 sess.tabs = nil
162- sess.active = -1
163 return retired165 return retired
164 }166 }
165 167
@@ -173,22 +175,14 @@ func moveSessionRetired(d *Daemon, sess *session) bool {
173 return !live175 return !live
174 }176 }
175 177
176-func destinationTabIsActive(sess *session, target *tab) bool {
177- if sess == nil || target == nil {
178- return false
179- }
180- sess.mu.Lock()
181- defer sess.mu.Unlock()
182- return sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == target
183-}
184-
185 func sessionClientIs(sess *session, client *attachedClient) bool {178 func sessionClientIs(sess *session, client *attachedClient) bool {
186 if sess == nil || client == nil {179 if sess == nil || client == nil {
187 return false180 return false
188 }181 }
189 sess.mu.Lock()182 sess.mu.Lock()
190 defer sess.mu.Unlock()183 defer sess.mu.Unlock()
191- return sess.client == client184+ _, ok := sess.attachments[client]
185+ return ok
192 }186 }
193 187
194 // retireEmptySessionAfterMove completes only source-owned lifecycle work. The188 // retireEmptySessionAfterMove completes only source-owned lifecycle work. The
M internal/usecase/daemon/move_picker.go +6 -4
@@ -16,8 +16,8 @@ func (d *Daemon) movePickerSourceError(source moveSourceLocator) error {
1616 }
1717 sess.mu.Lock()
1818 defer sess.mu.Unlock()
19- if source.Client != nil && sess.client != source.Client {
20- return domain.UserWarn(domain.NoticeSessionUnavailable, "Source client is no longer active.", errMovePaneInvalid)
19+ if source.Client != nil && !attachmentRegisteredLocked(sess, source.Client) {
20+ return domain.UserWarn(domain.NoticeSessionUnavailable, "Source attachment is no longer active.", errMovePaneInvalid)
2121 }
2222 tb := findMoveTabLocked(sess, source.TabID)
2323 if tb == nil {
@@ -90,6 +90,7 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc
9090 return errMovePaneInvalid
9191 }
9292 return d.movePane(movePaneRequest{
93+ Client: source.Client,
9394 Source: source.Session,
9495 SourceTabID: source.TabID,
9596 SourcePaneID: source.PaneID,
@@ -98,6 +99,7 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc
9899 })
99100 case pickerMoveTab:
100101 return d.moveTab(moveTabRequest{
102+ Client: source.Client,
101103 Source: source.Session,
102104 SourceTabID: source.TabID,
103105 Destination: destination,
@@ -121,10 +123,10 @@ func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*sess
121123 return nil, nil
122124 }
123125 if intent == pickerMoveTab {
124- if sess.active < 0 || sess.active >= len(sess.tabs) {
126+ if len(sess.tabs) == 0 {
125127 return nil, nil
126128 }
127- return sess, sess.tabs[sess.active]
129+ return sess, sess.tabs[0]
128130 }
129131 if target.TabID != "" {
130132 for _, tb := range sess.tabs {
@@ -16,8 +16,8 @@ func (d *Daemon) movePickerSourceError(source moveSourceLocator) error {
16 }16 }
17 sess.mu.Lock()17 sess.mu.Lock()
18 defer sess.mu.Unlock()18 defer sess.mu.Unlock()
19- if source.Client != nil && sess.client != source.Client {19+ if source.Client != nil && !attachmentRegisteredLocked(sess, source.Client) {
20- return domain.UserWarn(domain.NoticeSessionUnavailable, "Source client is no longer active.", errMovePaneInvalid)20+ return domain.UserWarn(domain.NoticeSessionUnavailable, "Source attachment is no longer active.", errMovePaneInvalid)
21 }21 }
22 tb := findMoveTabLocked(sess, source.TabID)22 tb := findMoveTabLocked(sess, source.TabID)
23 if tb == nil {23 if tb == nil {
@@ -90,6 +90,7 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc
90 return errMovePaneInvalid90 return errMovePaneInvalid
91 }91 }
92 return d.movePane(movePaneRequest{92 return d.movePane(movePaneRequest{
93+ Client: source.Client,
93 Source: source.Session,94 Source: source.Session,
94 SourceTabID: source.TabID,95 SourceTabID: source.TabID,
95 SourcePaneID: source.PaneID,96 SourcePaneID: source.PaneID,
@@ -98,6 +99,7 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc
98 })99 })
99 case pickerMoveTab:100 case pickerMoveTab:
100 return d.moveTab(moveTabRequest{101 return d.moveTab(moveTabRequest{
102+ Client: source.Client,
101 Source: source.Session,103 Source: source.Session,
102 SourceTabID: source.TabID,104 SourceTabID: source.TabID,
103 Destination: destination,105 Destination: destination,
@@ -121,10 +123,10 @@ func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*sess
121 return nil, nil123 return nil, nil
122 }124 }
123 if intent == pickerMoveTab {125 if intent == pickerMoveTab {
124- if sess.active < 0 || sess.active >= len(sess.tabs) {126+ if len(sess.tabs) == 0 {
125 return nil, nil127 return nil, nil
126 }128 }
127- return sess, sess.tabs[sess.active]129+ return sess, sess.tabs[0]
128 }130 }
129 if target.TabID != "" {131 if target.TabID != "" {
130 for _, tb := range sess.tabs {132 for _, tb := range sess.tabs {
M internal/usecase/daemon/move_tab.go +9 -19
@@ -214,10 +214,10 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio
214214 return nil, errMoveStaleTarget
215215 }
216216 moved := findMoveTabLocked(source, req.SourceTabID)
217- if moved == nil || destination.active < 0 || destination.active >= len(destination.tabs) {
217+ if moved == nil || len(destination.tabs) == 0 {
218218 return nil, errMoveStaleTarget
219219 }
220- destinationActive := destination.tabs[destination.active]
220+ destinationActive := destination.tabs[0]
221221 destinationActive.mu.Lock()
222222 destinationSize := destinationActive.size
223223 destinationActive.mu.Unlock()
@@ -242,7 +242,7 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio
242242 idx := indexMoveTabLocked(source, moved)
243243 return &moveTabAdmission{
244244 tab: moved, sourceIndex: idx,
245- sourceClient: source.client, sourceSnatched: snapshotMoveSnatchedLocked(source),
245+ sourceClient: req.Client, sourceSnatched: nil,
246246 layoutGeneration: moved.layoutGeneration, floatingState: moved.floating.state,
247247 floatingPane: moved.floating.pane, floatingGeneration: moved.floating.generation,
248248 panes: panes, destinationActive: destinationActive, destinationSize: destinationSize,
@@ -280,7 +280,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
280280 // precede moved.mu. The resize fences and ordered session locks keep topology
281281 // stable until the tab/pane checks below complete.
282282 if c.handoffFrozen {
283- if c.source.client != c.admission.sourceClient || !sameMoveSnatchedLocked(c.source, c.admission.sourceSnatched) {
283+ if (c.admission.sourceClient != nil && !attachmentRegisteredLocked(c.source, c.admission.sourceClient)) || !sameMoveSnatchedLocked(c.source, c.admission.sourceSnatched) {
284284 return false
285285 }
286286 publication, err := d.validateAttachmentTransitionPrelocked(c.handoffReq)
@@ -293,7 +293,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
293293 }
294294 var retirement frozenMoveAttachmentRetirement
295295 if c.admission.finalSource {
296- if c.source.client != c.admission.sourceClient || !sameMoveSnatchedLocked(c.source, c.admission.sourceSnatched) ||
296+ if (c.admission.sourceClient != nil && !attachmentRegisteredLocked(c.source, c.admission.sourceClient)) || !sameMoveSnatchedLocked(c.source, c.admission.sourceSnatched) ||
297297 (c.admission.sourceClient != nil) != c.handoffFrozen {
298298 return false
299299 }
@@ -303,7 +303,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
303303 return false
304304 }
305305 }
306- if c.destination.active < 0 || c.destination.active >= len(c.destination.tabs) || c.destination.tabs[c.destination.active] != c.admission.destinationActive {
306+ if len(c.destination.tabs) == 0 || c.destination.tabs[0] != c.admission.destinationActive {
307307 return false
308308 }
309309 c.admission.destinationActive.mu.Lock()
@@ -330,16 +330,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
330330
331331 idx := c.admission.sourceIndex
332332 c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...)
333- if len(c.source.tabs) == 0 {
334- c.source.active = -1
335- } else if c.source.active > idx {
336- c.source.active--
337- } else if c.source.active == idx {
338- if idx >= len(c.source.tabs) {
339- idx = len(c.source.tabs) - 1
340- }
341- c.source.active = idx
342- }
333+
343334 c.destination.tabs = append(c.destination.tabs, moved)
344335 moved.size = c.admission.destinationSize
345336 moved.bumpLayoutGenerationLocked()
@@ -367,7 +358,6 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
367358 d.beforeMoveTabCommit()
368359 }
369360 if c.handoffPublication != nil {
370- c.destination.active = len(c.destination.tabs) - 1
371361 c.handoffResult = d.publishAttachmentTransitionPrelocked(c.handoffPublication)
372362 c.handoffPublication.unlockCoordinators()
373363 c.handoffPublication = nil
@@ -380,8 +370,8 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
380370 }
381371 if c.handoffResult.published.ac != nil {
382372 c.sourceCleanupToken = c.handoffResult.published
383- } else if c.source.client != nil {
384- c.sourceCleanupToken = c.source.attachmentTokenLocked(c.source.client)
373+ } else if c.admission.sourceClient != nil {
374+ c.sourceCleanupToken = c.source.attachmentTokenLocked(c.admission.sourceClient)
385375 }
386376 if len(c.source.tabs) == 0 {
387377 d.unregisterSessionLocked(c.source)
@@ -214,10 +214,10 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio
214 return nil, errMoveStaleTarget214 return nil, errMoveStaleTarget
215 }215 }
216 moved := findMoveTabLocked(source, req.SourceTabID)216 moved := findMoveTabLocked(source, req.SourceTabID)
217- if moved == nil || destination.active < 0 || destination.active >= len(destination.tabs) {217+ if moved == nil || len(destination.tabs) == 0 {
218 return nil, errMoveStaleTarget218 return nil, errMoveStaleTarget
219 }219 }
220- destinationActive := destination.tabs[destination.active]220+ destinationActive := destination.tabs[0]
221 destinationActive.mu.Lock()221 destinationActive.mu.Lock()
222 destinationSize := destinationActive.size222 destinationSize := destinationActive.size
223 destinationActive.mu.Unlock()223 destinationActive.mu.Unlock()
@@ -242,7 +242,7 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio
242 idx := indexMoveTabLocked(source, moved)242 idx := indexMoveTabLocked(source, moved)
243 return &moveTabAdmission{243 return &moveTabAdmission{
244 tab: moved, sourceIndex: idx,244 tab: moved, sourceIndex: idx,
245- sourceClient: source.client, sourceSnatched: snapshotMoveSnatchedLocked(source),245+ sourceClient: req.Client, sourceSnatched: nil,
246 layoutGeneration: moved.layoutGeneration, floatingState: moved.floating.state,246 layoutGeneration: moved.layoutGeneration, floatingState: moved.floating.state,
247 floatingPane: moved.floating.pane, floatingGeneration: moved.floating.generation,247 floatingPane: moved.floating.pane, floatingGeneration: moved.floating.generation,
248 panes: panes, destinationActive: destinationActive, destinationSize: destinationSize,248 panes: panes, destinationActive: destinationActive, destinationSize: destinationSize,
@@ -280,7 +280,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
280 // precede moved.mu. The resize fences and ordered session locks keep topology280 // precede moved.mu. The resize fences and ordered session locks keep topology
281 // stable until the tab/pane checks below complete.281 // stable until the tab/pane checks below complete.
282 if c.handoffFrozen {282 if c.handoffFrozen {
283- if c.source.client != c.admission.sourceClient || !sameMoveSnatchedLocked(c.source, c.admission.sourceSnatched) {283+ if (c.admission.sourceClient != nil && !attachmentRegisteredLocked(c.source, c.admission.sourceClient)) || !sameMoveSnatchedLocked(c.source, c.admission.sourceSnatched) {
284 return false284 return false
285 }285 }
286 publication, err := d.validateAttachmentTransitionPrelocked(c.handoffReq)286 publication, err := d.validateAttachmentTransitionPrelocked(c.handoffReq)
@@ -293,7 +293,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
293 }293 }
294 var retirement frozenMoveAttachmentRetirement294 var retirement frozenMoveAttachmentRetirement
295 if c.admission.finalSource {295 if c.admission.finalSource {
296- if c.source.client != c.admission.sourceClient || !sameMoveSnatchedLocked(c.source, c.admission.sourceSnatched) ||296+ if (c.admission.sourceClient != nil && !attachmentRegisteredLocked(c.source, c.admission.sourceClient)) || !sameMoveSnatchedLocked(c.source, c.admission.sourceSnatched) ||
297 (c.admission.sourceClient != nil) != c.handoffFrozen {297 (c.admission.sourceClient != nil) != c.handoffFrozen {
298 return false298 return false
299 }299 }
@@ -303,7 +303,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
303 return false303 return false
304 }304 }
305 }305 }
306- if c.destination.active < 0 || c.destination.active >= len(c.destination.tabs) || c.destination.tabs[c.destination.active] != c.admission.destinationActive {306+ if len(c.destination.tabs) == 0 || c.destination.tabs[0] != c.admission.destinationActive {
307 return false307 return false
308 }308 }
309 c.admission.destinationActive.mu.Lock()309 c.admission.destinationActive.mu.Lock()
@@ -330,16 +330,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
330 330
331 idx := c.admission.sourceIndex331 idx := c.admission.sourceIndex
332 c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...)332 c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...)
333- if len(c.source.tabs) == 0 {333+
334- c.source.active = -1
335- } else if c.source.active > idx {
336- c.source.active--
337- } else if c.source.active == idx {
338- if idx >= len(c.source.tabs) {
339- idx = len(c.source.tabs) - 1
340- }
341- c.source.active = idx
342- }
343 c.destination.tabs = append(c.destination.tabs, moved)334 c.destination.tabs = append(c.destination.tabs, moved)
344 moved.size = c.admission.destinationSize335 moved.size = c.admission.destinationSize
345 moved.bumpLayoutGenerationLocked()336 moved.bumpLayoutGenerationLocked()
@@ -367,7 +358,6 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
367 d.beforeMoveTabCommit()358 d.beforeMoveTabCommit()
368 }359 }
369 if c.handoffPublication != nil {360 if c.handoffPublication != nil {
370- c.destination.active = len(c.destination.tabs) - 1
371 c.handoffResult = d.publishAttachmentTransitionPrelocked(c.handoffPublication)361 c.handoffResult = d.publishAttachmentTransitionPrelocked(c.handoffPublication)
372 c.handoffPublication.unlockCoordinators()362 c.handoffPublication.unlockCoordinators()
373 c.handoffPublication = nil363 c.handoffPublication = nil
@@ -380,8 +370,8 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
380 }370 }
381 if c.handoffResult.published.ac != nil {371 if c.handoffResult.published.ac != nil {
382 c.sourceCleanupToken = c.handoffResult.published372 c.sourceCleanupToken = c.handoffResult.published
383- } else if c.source.client != nil {373+ } else if c.admission.sourceClient != nil {
384- c.sourceCleanupToken = c.source.attachmentTokenLocked(c.source.client)374+ c.sourceCleanupToken = c.source.attachmentTokenLocked(c.admission.sourceClient)
385 }375 }
386 if len(c.source.tabs) == 0 {376 if len(c.source.tabs) == 0 {
387 d.unregisterSessionLocked(c.source)377 d.unregisterSessionLocked(c.source)
A internal/usecase/daemon/multi_attachment_callers_test.go +23 -0
@@ -0,0 +1,23 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestDaemonActionTargetsUseEachInitiatingAttachmentView(t *testing.T) {
11+ sess := registryTestSession()
12+ first, second := registryTestAttachment(1), registryTestAttachment(2)
13+ require.True(t, sess.registerAttachment(first))
14+ require.True(t, sess.registerAttachment(second))
15+ require.True(t, sess.selectAttachmentTab(second, domain.TabStableID(sess.tabs[1].stableID)))
16+
17+ firstTarget := resolveDaemonActionTargetForAttachment(sess, first)
18+ secondTarget := resolveDaemonActionTargetForAttachment(sess, second)
19+ require.Same(t, sess.tabs[0], firstTarget.tab)
20+ require.Same(t, sess.tabs[1], secondTarget.tab)
21+ require.Same(t, sess.tabs[0].focusedPane(), firstTarget.pane)
22+ require.Same(t, sess.tabs[1].focusedPane(), secondTarget.pane)
23+}
@@ -0,0 +1,23 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestDaemonActionTargetsUseEachInitiatingAttachmentView(t *testing.T) {
11+ sess := registryTestSession()
12+ first, second := registryTestAttachment(1), registryTestAttachment(2)
13+ require.True(t, sess.registerAttachment(first))
14+ require.True(t, sess.registerAttachment(second))
15+ require.True(t, sess.selectAttachmentTab(second, domain.TabStableID(sess.tabs[1].stableID)))
16+
17+ firstTarget := resolveDaemonActionTargetForAttachment(sess, first)
18+ secondTarget := resolveDaemonActionTargetForAttachment(sess, second)
19+ require.Same(t, sess.tabs[0], firstTarget.tab)
20+ require.Same(t, sess.tabs[1], secondTarget.tab)
21+ require.Same(t, sess.tabs[0].focusedPane(), firstTarget.pane)
22+ require.Same(t, sess.tabs[1].focusedPane(), secondTarget.pane)
23+}
M internal/usecase/daemon/navigation_overflow.go +21 -7
@@ -53,13 +53,13 @@ func resolveOverflow(dir layout.Direction, cfg domain.NavConfig, position, count
5353 // overflowSourceEligible validates that directional overflow still originates
5454 // from the active tiled tab. The session-to-tab lock order matches tab overflow
5555 // commit and leaves no lock held across a session handoff.
56-func overflowSourceEligible(sess *session, expectedSource *tab) bool {
56+func overflowSourceEligible(sess *session, ac *attachedClient, expectedSource *tab) bool {
5757 if sess == nil || expectedSource == nil {
5858 return false
5959 }
6060 sess.mu.Lock()
6161 defer sess.mu.Unlock()
62- if sess.active < 0 || sess.active >= len(sess.tabs) || sess.tabs[sess.active] != expectedSource {
62+ if ac == nil || !attachmentRegisteredLocked(sess, ac) || domain.TabStableID(expectedSource.stableID) != ac.viewSnapshot().tabID {
6363 return false
6464 }
6565 expectedSource.mu.Lock()
@@ -137,15 +137,25 @@ type tabOverflowCandidate struct {
137137 // prepareTabOverflow captures tab identities under the session lock and uses
138138 // EntryPane's pure lookup before any active-tab state is changed.
139139 func (d *Daemon) prepareTabOverflow(sess *session, expectedSource *tab, dir layout.Direction, span domain.Rect, delta int) (tabOverflowCandidate, bool) {
140+ return d.prepareTabOverflowForAttachment(sess, nil, expectedSource, dir, span, delta)
141+}
142+
143+func (d *Daemon) prepareTabOverflowForAttachment(sess *session, ac *attachedClient, expectedSource *tab, dir layout.Direction, span domain.Rect, delta int) (tabOverflowCandidate, bool) {
140144 if sess == nil || expectedSource == nil || (delta != -1 && delta != 1) {
141145 return tabOverflowCandidate{}, false
142146 }
143147 sess.mu.Lock()
144- if sess.active < 0 || sess.active >= len(sess.tabs) || sess.tabs[sess.active] != expectedSource {
148+ if ac != nil && (!attachmentRegisteredLocked(sess, ac) || domain.TabStableID(expectedSource.stableID) != ac.viewSnapshot().tabID) {
145149 sess.mu.Unlock()
146150 return tabOverflowCandidate{}, false
147151 }
148- sourceIndex := sess.active
152+ sourceIndex := 0
153+ for i, tb := range sess.tabs {
154+ if tb == expectedSource {
155+ sourceIndex = i
156+ break
157+ }
158+ }
149159 targetIndex := sourceIndex + delta
150160 if targetIndex < 0 || targetIndex >= len(sess.tabs) {
151161 sess.mu.Unlock()
@@ -193,13 +203,17 @@ func (d *Daemon) prepareTabOverflow(sess *session, expectedSource *tab, dir layo
193203 // The target layout and selected pane are revalidated while locked before the
194204 // focus and active-tab mutations become visible together.
195205 func (d *Daemon) commitTabOverflow(sess *session, candidate tabOverflowCandidate) bool {
206+ return d.commitTabOverflowForAttachment(sess, nil, candidate)
207+}
208+
209+func (d *Daemon) commitTabOverflowForAttachment(sess *session, ac *attachedClient, candidate tabOverflowCandidate) bool {
196210 if sess == nil || candidate.source == nil || candidate.target == nil {
197211 return false
198212 }
199213 sess.mu.Lock()
200214 targetIndex := candidate.sourceIndex + candidate.delta
201- if candidate.sourceIndex < 0 || candidate.sourceIndex >= len(sess.tabs) || targetIndex < 0 || targetIndex >= len(sess.tabs) ||
202- sess.active != candidate.sourceIndex || sess.tabs[candidate.sourceIndex] != candidate.source || sess.tabs[targetIndex] != candidate.target {
215+ if (ac != nil && (!attachmentRegisteredLocked(sess, ac) || domain.TabStableID(candidate.source.stableID) != ac.viewSnapshot().tabID)) || candidate.sourceIndex < 0 || candidate.sourceIndex >= len(sess.tabs) || targetIndex < 0 || targetIndex >= len(sess.tabs) ||
216+ sess.tabs[candidate.sourceIndex] != candidate.source || sess.tabs[targetIndex] != candidate.target {
203217 sess.mu.Unlock()
204218 return false
205219 }
@@ -234,10 +248,10 @@ func (d *Daemon) commitTabOverflow(sess *session, candidate tabOverflowCandidate
234248 }
235249 candidate.target.tree = targetTree
236250 candidate.target.bumpLayoutGenerationLocked()
237- sess.active = targetIndex
238251 candidate.target.mu.Unlock()
239252 candidate.source.mu.Unlock()
240253 sess.mu.Unlock()
254+ sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID))
241255 d.applyTabLayout(sess, candidate.target)
242256 return true
243257 }
@@ -53,13 +53,13 @@ func resolveOverflow(dir layout.Direction, cfg domain.NavConfig, position, count
53 // overflowSourceEligible validates that directional overflow still originates53 // overflowSourceEligible validates that directional overflow still originates
54 // from the active tiled tab. The session-to-tab lock order matches tab overflow54 // from the active tiled tab. The session-to-tab lock order matches tab overflow
55 // commit and leaves no lock held across a session handoff.55 // commit and leaves no lock held across a session handoff.
56-func overflowSourceEligible(sess *session, expectedSource *tab) bool {56+func overflowSourceEligible(sess *session, ac *attachedClient, expectedSource *tab) bool {
57 if sess == nil || expectedSource == nil {57 if sess == nil || expectedSource == nil {
58 return false58 return false
59 }59 }
60 sess.mu.Lock()60 sess.mu.Lock()
61 defer sess.mu.Unlock()61 defer sess.mu.Unlock()
62- if sess.active < 0 || sess.active >= len(sess.tabs) || sess.tabs[sess.active] != expectedSource {62+ if ac == nil || !attachmentRegisteredLocked(sess, ac) || domain.TabStableID(expectedSource.stableID) != ac.viewSnapshot().tabID {
63 return false63 return false
64 }64 }
65 expectedSource.mu.Lock()65 expectedSource.mu.Lock()
@@ -137,15 +137,25 @@ type tabOverflowCandidate struct {
137 // prepareTabOverflow captures tab identities under the session lock and uses137 // prepareTabOverflow captures tab identities under the session lock and uses
138 // EntryPane's pure lookup before any active-tab state is changed.138 // EntryPane's pure lookup before any active-tab state is changed.
139 func (d *Daemon) prepareTabOverflow(sess *session, expectedSource *tab, dir layout.Direction, span domain.Rect, delta int) (tabOverflowCandidate, bool) {139 func (d *Daemon) prepareTabOverflow(sess *session, expectedSource *tab, dir layout.Direction, span domain.Rect, delta int) (tabOverflowCandidate, bool) {
140+ return d.prepareTabOverflowForAttachment(sess, nil, expectedSource, dir, span, delta)
141+}
142+
143+func (d *Daemon) prepareTabOverflowForAttachment(sess *session, ac *attachedClient, expectedSource *tab, dir layout.Direction, span domain.Rect, delta int) (tabOverflowCandidate, bool) {
140 if sess == nil || expectedSource == nil || (delta != -1 && delta != 1) {144 if sess == nil || expectedSource == nil || (delta != -1 && delta != 1) {
141 return tabOverflowCandidate{}, false145 return tabOverflowCandidate{}, false
142 }146 }
143 sess.mu.Lock()147 sess.mu.Lock()
144- if sess.active < 0 || sess.active >= len(sess.tabs) || sess.tabs[sess.active] != expectedSource {148+ if ac != nil && (!attachmentRegisteredLocked(sess, ac) || domain.TabStableID(expectedSource.stableID) != ac.viewSnapshot().tabID) {
145 sess.mu.Unlock()149 sess.mu.Unlock()
146 return tabOverflowCandidate{}, false150 return tabOverflowCandidate{}, false
147 }151 }
148- sourceIndex := sess.active152+ sourceIndex := 0
153+ for i, tb := range sess.tabs {
154+ if tb == expectedSource {
155+ sourceIndex = i
156+ break
157+ }
158+ }
149 targetIndex := sourceIndex + delta159 targetIndex := sourceIndex + delta
150 if targetIndex < 0 || targetIndex >= len(sess.tabs) {160 if targetIndex < 0 || targetIndex >= len(sess.tabs) {
151 sess.mu.Unlock()161 sess.mu.Unlock()
@@ -193,13 +203,17 @@ func (d *Daemon) prepareTabOverflow(sess *session, expectedSource *tab, dir layo
193 // The target layout and selected pane are revalidated while locked before the203 // The target layout and selected pane are revalidated while locked before the
194 // focus and active-tab mutations become visible together.204 // focus and active-tab mutations become visible together.
195 func (d *Daemon) commitTabOverflow(sess *session, candidate tabOverflowCandidate) bool {205 func (d *Daemon) commitTabOverflow(sess *session, candidate tabOverflowCandidate) bool {
206+ return d.commitTabOverflowForAttachment(sess, nil, candidate)
207+}
208+
209+func (d *Daemon) commitTabOverflowForAttachment(sess *session, ac *attachedClient, candidate tabOverflowCandidate) bool {
196 if sess == nil || candidate.source == nil || candidate.target == nil {210 if sess == nil || candidate.source == nil || candidate.target == nil {
197 return false211 return false
198 }212 }
199 sess.mu.Lock()213 sess.mu.Lock()
200 targetIndex := candidate.sourceIndex + candidate.delta214 targetIndex := candidate.sourceIndex + candidate.delta
201- if candidate.sourceIndex < 0 || candidate.sourceIndex >= len(sess.tabs) || targetIndex < 0 || targetIndex >= len(sess.tabs) ||215+ if (ac != nil && (!attachmentRegisteredLocked(sess, ac) || domain.TabStableID(candidate.source.stableID) != ac.viewSnapshot().tabID)) || candidate.sourceIndex < 0 || candidate.sourceIndex >= len(sess.tabs) || targetIndex < 0 || targetIndex >= len(sess.tabs) ||
202- sess.active != candidate.sourceIndex || sess.tabs[candidate.sourceIndex] != candidate.source || sess.tabs[targetIndex] != candidate.target {216+ sess.tabs[candidate.sourceIndex] != candidate.source || sess.tabs[targetIndex] != candidate.target {
203 sess.mu.Unlock()217 sess.mu.Unlock()
204 return false218 return false
205 }219 }
@@ -234,10 +248,10 @@ func (d *Daemon) commitTabOverflow(sess *session, candidate tabOverflowCandidate
234 }248 }
235 candidate.target.tree = targetTree249 candidate.target.tree = targetTree
236 candidate.target.bumpLayoutGenerationLocked()250 candidate.target.bumpLayoutGenerationLocked()
237- sess.active = targetIndex
238 candidate.target.mu.Unlock()251 candidate.target.mu.Unlock()
239 candidate.source.mu.Unlock()252 candidate.source.mu.Unlock()
240 sess.mu.Unlock()253 sess.mu.Unlock()
254+ sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID))
241 d.applyTabLayout(sess, candidate.target)255 d.applyTabLayout(sess, candidate.target)
242 return true256 return true
243 }257 }
M internal/usecase/daemon/notify.go +15 -14
@@ -267,9 +267,9 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No
267267 if sess != nil {
268268 d.notices.routingMu.Lock()
269269 sess.mu.Lock()
270- ac := sess.client
270+ attachments := sess.snapshotAttachmentsLocked()
271271 sess.mu.Unlock()
272- if ac == nil {
272+ if len(attachments) == 0 {
273273 d.notices.queueGlobal(n)
274274 d.notices.routingMu.Unlock()
275275 return
@@ -277,11 +277,14 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No
277277 if d.notices.beforeSessionDelivery != nil {
278278 d.notices.beforeSessionDelivery()
279279 }
280- // Publish the toast while detach is excluded, then release routing before
281- // repainting. Rendering can report another notice and re-enter routing.
282- published := d.publishToast(ac, n)
280+ published := make([]*attachedClient, 0, len(attachments))
281+ for _, ac := range attachments {
282+ if d.publishToast(ac, n) {
283+ published = append(published, ac)
284+ }
285+ }
283286 d.notices.routingMu.Unlock()
284- if published {
287+ for _, ac := range published {
285288 d.repaintForNotice(ac)
286289 }
287290 return
@@ -308,14 +311,12 @@ func (d *Daemon) deliverGlobal(n domain.Notification) {
308311 }
309312 targets := make([]*attachedClient, 0, len(sessions))
310313 for _, s := range sessions {
311- core := s.core()
312- core.mu.Lock()
313- ac := core.client
314- core.mu.Unlock()
315- if ac == nil || !d.publishToast(ac, n) {
316- continue
314+ for _, ac := range snapshotAttachmentSession(s) {
315+ if !d.publishToast(ac, n) {
316+ continue
317+ }
318+ targets = append(targets, ac)
317319 }
318- targets = append(targets, ac)
319320 }
320321 if len(targets) == 0 {
321322 if d.notices.beforeQueueGlobal != nil {
@@ -338,7 +339,7 @@ func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) {
338339 d.notices.routingMu.Lock()
339340
340341 sess.mu.Lock()
341- current := sess.client == ac
342+ current := attachmentRegisteredLocked(sess, ac)
342343 sess.mu.Unlock()
343344 if !current {
344345 d.notices.routingMu.Unlock()
@@ -267,9 +267,9 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No
267 if sess != nil {267 if sess != nil {
268 d.notices.routingMu.Lock()268 d.notices.routingMu.Lock()
269 sess.mu.Lock()269 sess.mu.Lock()
270- ac := sess.client270+ attachments := sess.snapshotAttachmentsLocked()
271 sess.mu.Unlock()271 sess.mu.Unlock()
272- if ac == nil {272+ if len(attachments) == 0 {
273 d.notices.queueGlobal(n)273 d.notices.queueGlobal(n)
274 d.notices.routingMu.Unlock()274 d.notices.routingMu.Unlock()
275 return275 return
@@ -277,11 +277,14 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No
277 if d.notices.beforeSessionDelivery != nil {277 if d.notices.beforeSessionDelivery != nil {
278 d.notices.beforeSessionDelivery()278 d.notices.beforeSessionDelivery()
279 }279 }
280- // Publish the toast while detach is excluded, then release routing before280+ published := make([]*attachedClient, 0, len(attachments))
281- // repainting. Rendering can report another notice and re-enter routing.281+ for _, ac := range attachments {
282- published := d.publishToast(ac, n)282+ if d.publishToast(ac, n) {
283+ published = append(published, ac)
284+ }
285+ }
283 d.notices.routingMu.Unlock()286 d.notices.routingMu.Unlock()
284- if published {287+ for _, ac := range published {
285 d.repaintForNotice(ac)288 d.repaintForNotice(ac)
286 }289 }
287 return290 return
@@ -308,14 +311,12 @@ func (d *Daemon) deliverGlobal(n domain.Notification) {
308 }311 }
309 targets := make([]*attachedClient, 0, len(sessions))312 targets := make([]*attachedClient, 0, len(sessions))
310 for _, s := range sessions {313 for _, s := range sessions {
311- core := s.core()314+ for _, ac := range snapshotAttachmentSession(s) {
312- core.mu.Lock()315+ if !d.publishToast(ac, n) {
313- ac := core.client316+ continue
314- core.mu.Unlock()317+ }
315- if ac == nil || !d.publishToast(ac, n) {318+ targets = append(targets, ac)
316- continue
317 }319 }
318- targets = append(targets, ac)
319 }320 }
320 if len(targets) == 0 {321 if len(targets) == 0 {
321 if d.notices.beforeQueueGlobal != nil {322 if d.notices.beforeQueueGlobal != nil {
@@ -338,7 +339,7 @@ func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) {
338 d.notices.routingMu.Lock()339 d.notices.routingMu.Lock()
339 340
340 sess.mu.Lock()341 sess.mu.Lock()
341- current := sess.client == ac342+ current := attachmentRegisteredLocked(sess, ac)
342 sess.mu.Unlock()343 sess.mu.Unlock()
343 if !current {344 if !current {
344 d.notices.routingMu.Unlock()345 d.notices.routingMu.Unlock()
M internal/usecase/daemon/pane_actions.go +11 -16
@@ -190,7 +190,8 @@ func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error {
190190 }
191191 return nil
192192 }
193- tb := sess.activeTab()
193+ target := resolveDaemonActionTargetForAttachment(sess, ac)
194+ tb := target.tab
194195 if tb == nil {
195196 return layout.ErrNotFound
196197 }
@@ -254,7 +255,7 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
254255 sess.mu.Unlock()
255256 return true
256257 }
257- ac := sess.client
258+ attachments := sess.snapshotAttachmentsLocked()
258259 sessionName := sess.name
259260 tb.mu.Lock()
260261 if p.owner.Load() != owner {
@@ -295,7 +296,7 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
295296 return true
296297 }
297298 d.applyTabLayout(sess, tb)
298- if ac != nil {
299+ for _, ac := range attachments {
299300 ac.overlays.clearCopyModeForPane(p)
300301 ac.pruneCaptureFrames(p)
301302 }
@@ -304,7 +305,7 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
304305 }
305306 closePaneProcess(p)
306307 d.log.Info("pane closed", "session", sessionName, "pane", p.id)
307- if ac != nil {
308+ for _, ac := range attachments {
308309 d.invalidateRender(sess, ac, true, "pane_actions.go")
309310 }
310311 return true
@@ -314,11 +315,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
314315 if tb == nil {
315316 return layout.ErrNotFound
316317 }
317- if ac == nil {
318- sess.mu.Lock()
319- ac = sess.client
320- sess.mu.Unlock()
321- }
318+ // A nil attachment denotes a session-wide close with an explicit pane target.
322319 tb.mu.Lock()
323320 p := tb.panes[id]
324321 if p == nil || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, id) {
@@ -375,7 +372,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
375372 if !errors.Is(err, errNoNeighbor) || target.tab == nil {
376373 return err
377374 }
378- if !overflowSourceEligible(sess, target.tab) {
375+ if !overflowSourceEligible(sess, ac, target.tab) {
379376 return errNoNeighbor
380377 }
381378
@@ -390,19 +387,17 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
390387 return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)
391388 }
392389
393- sess.mu.Lock()
394- position, count := sess.active, len(sess.tabs)
395- sess.mu.Unlock()
390+ position, count := sess.tabIndexForAttachment(ac)
396391 step := resolveOverflow(dir, cfg, position, count)
397392 if step.kind != overflowTabs {
398393 return err
399394 }
400- candidate, ok := d.prepareTabOverflow(sess, target.tab, dir, span, step.delta)
401- if !ok || !d.commitTabOverflow(sess, candidate) {
395+ candidate, ok := d.prepareTabOverflowForAttachment(sess, ac, target.tab, dir, span, step.delta)
396+ if !ok || !d.commitTabOverflowForAttachment(sess, ac, candidate) {
402397 return errNoNeighbor
403398 }
404- d.activateTab(sess, candidate.target)
405399 if ac != nil {
400+ sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID))
406401 d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "pane_actions.go")
407402 }
408403 return nil
@@ -190,7 +190,8 @@ func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error {
190 }190 }
191 return nil191 return nil
192 }192 }
193- tb := sess.activeTab()193+ target := resolveDaemonActionTargetForAttachment(sess, ac)
194+ tb := target.tab
194 if tb == nil {195 if tb == nil {
195 return layout.ErrNotFound196 return layout.ErrNotFound
196 }197 }
@@ -254,7 +255,7 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
254 sess.mu.Unlock()255 sess.mu.Unlock()
255 return true256 return true
256 }257 }
257- ac := sess.client258+ attachments := sess.snapshotAttachmentsLocked()
258 sessionName := sess.name259 sessionName := sess.name
259 tb.mu.Lock()260 tb.mu.Lock()
260 if p.owner.Load() != owner {261 if p.owner.Load() != owner {
@@ -295,7 +296,7 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
295 return true296 return true
296 }297 }
297 d.applyTabLayout(sess, tb)298 d.applyTabLayout(sess, tb)
298- if ac != nil {299+ for _, ac := range attachments {
299 ac.overlays.clearCopyModeForPane(p)300 ac.overlays.clearCopyModeForPane(p)
300 ac.pruneCaptureFrames(p)301 ac.pruneCaptureFrames(p)
301 }302 }
@@ -304,7 +305,7 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
304 }305 }
305 closePaneProcess(p)306 closePaneProcess(p)
306 d.log.Info("pane closed", "session", sessionName, "pane", p.id)307 d.log.Info("pane closed", "session", sessionName, "pane", p.id)
307- if ac != nil {308+ for _, ac := range attachments {
308 d.invalidateRender(sess, ac, true, "pane_actions.go")309 d.invalidateRender(sess, ac, true, "pane_actions.go")
309 }310 }
310 return true311 return true
@@ -314,11 +315,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
314 if tb == nil {315 if tb == nil {
315 return layout.ErrNotFound316 return layout.ErrNotFound
316 }317 }
317- if ac == nil {318+ // A nil attachment denotes a session-wide close with an explicit pane target.
318- sess.mu.Lock()
319- ac = sess.client
320- sess.mu.Unlock()
321- }
322 tb.mu.Lock()319 tb.mu.Lock()
323 p := tb.panes[id]320 p := tb.panes[id]
324 if p == nil || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, id) {321 if p == nil || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, id) {
@@ -375,7 +372,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
375 if !errors.Is(err, errNoNeighbor) || target.tab == nil {372 if !errors.Is(err, errNoNeighbor) || target.tab == nil {
376 return err373 return err
377 }374 }
378- if !overflowSourceEligible(sess, target.tab) {375+ if !overflowSourceEligible(sess, ac, target.tab) {
379 return errNoNeighbor376 return errNoNeighbor
380 }377 }
381 378
@@ -390,19 +387,17 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
390 return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)387 return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)
391 }388 }
392 389
393- sess.mu.Lock()390+ position, count := sess.tabIndexForAttachment(ac)
394- position, count := sess.active, len(sess.tabs)
395- sess.mu.Unlock()
396 step := resolveOverflow(dir, cfg, position, count)391 step := resolveOverflow(dir, cfg, position, count)
397 if step.kind != overflowTabs {392 if step.kind != overflowTabs {
398 return err393 return err
399 }394 }
400- candidate, ok := d.prepareTabOverflow(sess, target.tab, dir, span, step.delta)395+ candidate, ok := d.prepareTabOverflowForAttachment(sess, ac, target.tab, dir, span, step.delta)
401- if !ok || !d.commitTabOverflow(sess, candidate) {396+ if !ok || !d.commitTabOverflowForAttachment(sess, ac, candidate) {
402 return errNoNeighbor397 return errNoNeighbor
403 }398 }
404- d.activateTab(sess, candidate.target)
405 if ac != nil {399 if ac != nil {
400+ sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID))
406 d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "pane_actions.go")401 d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "pane_actions.go")
407 }402 }
408 return nil403 return nil
M internal/usecase/daemon/pane_titles.go +1 -1
@@ -151,7 +151,7 @@ func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force b
151151 if sess == nil {
152152 return fallback
153153 }
154- tb := sess.activeTab()
154+ tb := sess.tabForAttachmentOrActive(nil)
155155 if tb == nil {
156156 return fallback
157157 }
@@ -151,7 +151,7 @@ func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force b
151 if sess == nil {151 if sess == nil {
152 return fallback152 return fallback
153 }153 }
154- tb := sess.activeTab()154+ tb := sess.tabForAttachmentOrActive(nil)
155 if tb == nil {155 if tb == nil {
156 return fallback156 return fallback
157 }157 }
M internal/usecase/daemon/picker.go +15 -11
@@ -489,9 +489,7 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
489489 continue
490490 }
491491 sess.mu.Lock()
492- if sess.client != nil {
493- clients = append(clients, sess.client)
494- }
492+ clients = append(clients, sess.snapshotAttachmentsLocked()...)
495493 sess.mu.Unlock()
496494 }
497495 d.mu.Unlock()
@@ -759,7 +757,7 @@ func (d *Daemon) discardUnattachedProxyGeneration(proxy *proxySession, generatio
759757 return false
760758 }
761759 proxy.sessionCore.mu.Lock()
762- if proxy.client != nil {
760+ if len(proxy.attachments) != 0 {
763761 proxy.sessionCore.mu.Unlock()
764762 d.mu.Unlock()
765763 return false
@@ -879,12 +877,12 @@ func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient,
879877 if sourceToken != nil {
880878 fresh, admitted := ac.beginRoleEffect(transition.published)
881879 if admitted {
882- d.activateTab(from, from.activeTab())
880+ d.activateTabAfterResizeForLease(from, from.tabForAttachmentOrActive(ac), false, ac, transition.published.lease)
883881 d.invalidateRender(from, ac, true, "picker.go")
884882 fresh.End()
885883 }
886884 } else {
887- d.activateTab(from, from.activeTab())
885+ d.activateTabAfterResizeForLease(from, from.tabForAttachmentOrActive(ac), false, ac, nil)
888886 d.invalidateRender(from, ac, true, "picker.go")
889887 }
890888 } else {
@@ -923,10 +921,13 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
923921 if sourceToken == nil {
924922 targetSess.mu.Lock()
925923 defer targetSess.mu.Unlock()
926- if targetSess.client != ac || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {
924+ _, attached := targetSess.attachments[ac]
925+ if !attached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {
926+ return nil, attachmentTransitionResult{}, false
927+ }
928+ if !targetSess.activateAttachmentViewLocked(ac, target.TabIndex) {
927929 return nil, attachmentTransitionResult{}, false
928930 }
929- targetSess.active = target.TabIndex
930931 return targetSess, attachmentTransitionResult{}, true
931932 }
932933 d.mu.Unlock()
@@ -941,12 +942,14 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
941942 }
942943
943944 unlock := lockAttachmentSessions(from, targetSess)
944- if from.client != ac || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) {
945+ _, sourceAttached := from.attachments[ac]
946+ if !sourceAttached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) {
945947 unlock()
946948 return nil, attachmentTransitionResult{}, false
947949 }
948950 if guard.expectedSource != nil {
949- if from.active < 0 || from.active >= len(from.tabs) || from.tabs[from.active] != guard.expectedSource {
951+ view := ac.viewSnapshot()
952+ if domain.TabStableID(guard.expectedSource.stableID) != view.tabID {
950953 unlock()
951954 return nil, attachmentTransitionResult{}, false
952955 }
@@ -1034,7 +1037,8 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
10341037 }
10351038
10361039 from.mu.Lock()
1037- if from.client != ac {
1040+ _, attached := from.attachments[ac]
1041+ if !attached {
10381042 from.mu.Unlock()
10391043 return nil, attachmentTransitionResult{}, false, nil
10401044 }
@@ -489,9 +489,7 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
489 continue489 continue
490 }490 }
491 sess.mu.Lock()491 sess.mu.Lock()
492- if sess.client != nil {492+ clients = append(clients, sess.snapshotAttachmentsLocked()...)
493- clients = append(clients, sess.client)
494- }
495 sess.mu.Unlock()493 sess.mu.Unlock()
496 }494 }
497 d.mu.Unlock()495 d.mu.Unlock()
@@ -759,7 +757,7 @@ func (d *Daemon) discardUnattachedProxyGeneration(proxy *proxySession, generatio
759 return false757 return false
760 }758 }
761 proxy.sessionCore.mu.Lock()759 proxy.sessionCore.mu.Lock()
762- if proxy.client != nil {760+ if len(proxy.attachments) != 0 {
763 proxy.sessionCore.mu.Unlock()761 proxy.sessionCore.mu.Unlock()
764 d.mu.Unlock()762 d.mu.Unlock()
765 return false763 return false
@@ -879,12 +877,12 @@ func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient,
879 if sourceToken != nil {877 if sourceToken != nil {
880 fresh, admitted := ac.beginRoleEffect(transition.published)878 fresh, admitted := ac.beginRoleEffect(transition.published)
881 if admitted {879 if admitted {
882- d.activateTab(from, from.activeTab())880+ d.activateTabAfterResizeForLease(from, from.tabForAttachmentOrActive(ac), false, ac, transition.published.lease)
883 d.invalidateRender(from, ac, true, "picker.go")881 d.invalidateRender(from, ac, true, "picker.go")
884 fresh.End()882 fresh.End()
885 }883 }
886 } else {884 } else {
887- d.activateTab(from, from.activeTab())885+ d.activateTabAfterResizeForLease(from, from.tabForAttachmentOrActive(ac), false, ac, nil)
888 d.invalidateRender(from, ac, true, "picker.go")886 d.invalidateRender(from, ac, true, "picker.go")
889 }887 }
890 } else {888 } else {
@@ -923,10 +921,13 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
923 if sourceToken == nil {921 if sourceToken == nil {
924 targetSess.mu.Lock()922 targetSess.mu.Lock()
925 defer targetSess.mu.Unlock()923 defer targetSess.mu.Unlock()
926- if targetSess.client != ac || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {924+ _, attached := targetSess.attachments[ac]
925+ if !attached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {
926+ return nil, attachmentTransitionResult{}, false
927+ }
928+ if !targetSess.activateAttachmentViewLocked(ac, target.TabIndex) {
927 return nil, attachmentTransitionResult{}, false929 return nil, attachmentTransitionResult{}, false
928 }930 }
929- targetSess.active = target.TabIndex
930 return targetSess, attachmentTransitionResult{}, true931 return targetSess, attachmentTransitionResult{}, true
931 }932 }
932 d.mu.Unlock()933 d.mu.Unlock()
@@ -941,12 +942,14 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
941 }942 }
942 943
943 unlock := lockAttachmentSessions(from, targetSess)944 unlock := lockAttachmentSessions(from, targetSess)
944- if from.client != ac || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) {945+ _, sourceAttached := from.attachments[ac]
946+ if !sourceAttached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) {
945 unlock()947 unlock()
946 return nil, attachmentTransitionResult{}, false948 return nil, attachmentTransitionResult{}, false
947 }949 }
948 if guard.expectedSource != nil {950 if guard.expectedSource != nil {
949- if from.active < 0 || from.active >= len(from.tabs) || from.tabs[from.active] != guard.expectedSource {951+ view := ac.viewSnapshot()
952+ if domain.TabStableID(guard.expectedSource.stableID) != view.tabID {
950 unlock()953 unlock()
951 return nil, attachmentTransitionResult{}, false954 return nil, attachmentTransitionResult{}, false
952 }955 }
@@ -1034,7 +1037,8 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
1034 }1037 }
1035 1038
1036 from.mu.Lock()1039 from.mu.Lock()
1037- if from.client != ac {1040+ _, attached := from.attachments[ac]
1041+ if !attached {
1038 from.mu.Unlock()1042 from.mu.Unlock()
1039 return nil, attachmentTransitionResult{}, false, nil1043 return nil, attachmentTransitionResult{}, false, nil
1040 }1044 }
M internal/usecase/daemon/proxy_input.go +5 -7
@@ -239,22 +239,20 @@ func (d *Daemon) focusDirProxied(sess *session, ac *attachedClient, dir layout.D
239239 }
240240 return nil
241241 }
242- if !errors.Is(err, errNoNeighbor) || target.tab == nil || !overflowSourceEligible(sess, target.tab) {
242+ if !errors.Is(err, errNoNeighbor) || target.tab == nil || !overflowSourceEligible(sess, ac, target.tab) {
243243 return err
244244 }
245245
246- sess.mu.Lock()
247- position, count := sess.active, len(sess.tabs)
248- sess.mu.Unlock()
246+ position, count := sess.tabIndexForAttachment(ac)
249247 step := resolveOverflow(dir, proxiedNavConfig(d.currentNavConfig()), position, count)
250248 if step.kind != overflowTabs {
251249 return errNoNeighbor
252250 }
253- candidate, ok := d.prepareTabOverflow(sess, target.tab, dir, span, step.delta)
254- if !ok || !d.commitTabOverflow(sess, candidate) {
251+ candidate, ok := d.prepareTabOverflowForAttachment(sess, ac, target.tab, dir, span, step.delta)
252+ if !ok || !d.commitTabOverflowForAttachment(sess, ac, candidate) {
255253 return errNoNeighbor
256254 }
257- d.activateTab(sess, candidate.target)
255+ sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID))
258256 if ac != nil {
259257 d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "proxy_input.go")
260258 }
@@ -239,22 +239,20 @@ func (d *Daemon) focusDirProxied(sess *session, ac *attachedClient, dir layout.D
239 }239 }
240 return nil240 return nil
241 }241 }
242- if !errors.Is(err, errNoNeighbor) || target.tab == nil || !overflowSourceEligible(sess, target.tab) {242+ if !errors.Is(err, errNoNeighbor) || target.tab == nil || !overflowSourceEligible(sess, ac, target.tab) {
243 return err243 return err
244 }244 }
245 245
246- sess.mu.Lock()246+ position, count := sess.tabIndexForAttachment(ac)
247- position, count := sess.active, len(sess.tabs)
248- sess.mu.Unlock()
249 step := resolveOverflow(dir, proxiedNavConfig(d.currentNavConfig()), position, count)247 step := resolveOverflow(dir, proxiedNavConfig(d.currentNavConfig()), position, count)
250 if step.kind != overflowTabs {248 if step.kind != overflowTabs {
251 return errNoNeighbor249 return errNoNeighbor
252 }250 }
253- candidate, ok := d.prepareTabOverflow(sess, target.tab, dir, span, step.delta)251+ candidate, ok := d.prepareTabOverflowForAttachment(sess, ac, target.tab, dir, span, step.delta)
254- if !ok || !d.commitTabOverflow(sess, candidate) {252+ if !ok || !d.commitTabOverflowForAttachment(sess, ac, candidate) {
255 return errNoNeighbor253 return errNoNeighbor
256 }254 }
257- d.activateTab(sess, candidate.target)255+ sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID))
258 if ac != nil {256 if ac != nil {
259 d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "proxy_input.go")257 d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "proxy_input.go")
260 }258 }
M internal/usecase/daemon/proxy_lifecycle.go +10 -8
@@ -66,7 +66,7 @@ func (d *Daemon) cancelProxyWarmForAttachment(p *proxySession, ac *attachedClien
6666 return false
6767 }
6868 p.sessionCore.mu.Lock()
69- if p.client != ac {
69+ if !attachmentRegisteredLocked(attachmentSession(p), ac) {
7070 p.sessionCore.mu.Unlock()
7171 d.mu.Unlock()
7272 return false
@@ -100,7 +100,7 @@ func (d *Daemon) armProxyWarm(p *proxySession) bool {
100100 return false
101101 }
102102 p.sessionCore.mu.Lock()
103- if p.client != nil {
103+ if len(p.attachments) != 0 {
104104 p.sessionCore.mu.Unlock()
105105 d.mu.Unlock()
106106 return false
@@ -126,7 +126,7 @@ func (d *Daemon) armProxyWarm(p *proxySession) bool {
126126 valid := d.sessions[p.id] == p && !d.closing
127127 if valid {
128128 p.sessionCore.mu.Lock()
129- valid = p.client == nil
129+ valid = len(p.attachments) == 0
130130 if valid {
131131 p.mu.Lock()
132132 valid = p.generation == generation && p.warm == old
@@ -174,7 +174,7 @@ func (d *Daemon) expireWarmProxy(p *proxySession, token *proxyWarmTimer) bool {
174174 return false
175175 }
176176 p.sessionCore.mu.Lock()
177- if p.client != nil {
177+ if len(p.attachments) != 0 {
178178 p.sessionCore.mu.Unlock()
179179 d.mu.Unlock()
180180 return false
@@ -245,12 +245,14 @@ func (d *Daemon) withProxyLocked(p *proxySession, update func() bool) bool {
245245 p.sessionCore.mu.Lock()
246246 p.mu.Lock()
247247 current := update()
248- ac := p.client
248+ attachments := append([]*attachedClient(nil), p.sessionCore.snapshotAttachmentsLocked()...)
249249 p.mu.Unlock()
250250 p.sessionCore.mu.Unlock()
251251 d.mu.Unlock()
252- if current && ac != nil {
253- d.invalidateRender(p, ac, false, "proxy_lifecycle.go")
252+ if current {
253+ for _, ac := range attachments {
254+ d.invalidateRender(p, ac, false, "proxy_lifecycle.go")
255+ }
254256 }
255257 return current
256258 }
@@ -317,7 +319,7 @@ func (d *Daemon) markProxyReplaced(p *proxySession, generation uint64, transport
317319 p.warm.generation = p.generation
318320 }
319321 }
320- needsWarm = current && p.client == nil && p.warm == nil
322+ needsWarm = current && len(p.attachments) == 0 && p.warm == nil
321323 return current
322324 })
323325 if needsWarm {
@@ -66,7 +66,7 @@ func (d *Daemon) cancelProxyWarmForAttachment(p *proxySession, ac *attachedClien
66 return false66 return false
67 }67 }
68 p.sessionCore.mu.Lock()68 p.sessionCore.mu.Lock()
69- if p.client != ac {69+ if !attachmentRegisteredLocked(attachmentSession(p), ac) {
70 p.sessionCore.mu.Unlock()70 p.sessionCore.mu.Unlock()
71 d.mu.Unlock()71 d.mu.Unlock()
72 return false72 return false
@@ -100,7 +100,7 @@ func (d *Daemon) armProxyWarm(p *proxySession) bool {
100 return false100 return false
101 }101 }
102 p.sessionCore.mu.Lock()102 p.sessionCore.mu.Lock()
103- if p.client != nil {103+ if len(p.attachments) != 0 {
104 p.sessionCore.mu.Unlock()104 p.sessionCore.mu.Unlock()
105 d.mu.Unlock()105 d.mu.Unlock()
106 return false106 return false
@@ -126,7 +126,7 @@ func (d *Daemon) armProxyWarm(p *proxySession) bool {
126 valid := d.sessions[p.id] == p && !d.closing126 valid := d.sessions[p.id] == p && !d.closing
127 if valid {127 if valid {
128 p.sessionCore.mu.Lock()128 p.sessionCore.mu.Lock()
129- valid = p.client == nil129+ valid = len(p.attachments) == 0
130 if valid {130 if valid {
131 p.mu.Lock()131 p.mu.Lock()
132 valid = p.generation == generation && p.warm == old132 valid = p.generation == generation && p.warm == old
@@ -174,7 +174,7 @@ func (d *Daemon) expireWarmProxy(p *proxySession, token *proxyWarmTimer) bool {
174 return false174 return false
175 }175 }
176 p.sessionCore.mu.Lock()176 p.sessionCore.mu.Lock()
177- if p.client != nil {177+ if len(p.attachments) != 0 {
178 p.sessionCore.mu.Unlock()178 p.sessionCore.mu.Unlock()
179 d.mu.Unlock()179 d.mu.Unlock()
180 return false180 return false
@@ -245,12 +245,14 @@ func (d *Daemon) withProxyLocked(p *proxySession, update func() bool) bool {
245 p.sessionCore.mu.Lock()245 p.sessionCore.mu.Lock()
246 p.mu.Lock()246 p.mu.Lock()
247 current := update()247 current := update()
248- ac := p.client248+ attachments := append([]*attachedClient(nil), p.sessionCore.snapshotAttachmentsLocked()...)
249 p.mu.Unlock()249 p.mu.Unlock()
250 p.sessionCore.mu.Unlock()250 p.sessionCore.mu.Unlock()
251 d.mu.Unlock()251 d.mu.Unlock()
252- if current && ac != nil {252+ if current {
253- d.invalidateRender(p, ac, false, "proxy_lifecycle.go")253+ for _, ac := range attachments {
254+ d.invalidateRender(p, ac, false, "proxy_lifecycle.go")
255+ }
254 }256 }
255 return current257 return current
256 }258 }
@@ -317,7 +319,7 @@ func (d *Daemon) markProxyReplaced(p *proxySession, generation uint64, transport
317 p.warm.generation = p.generation319 p.warm.generation = p.generation
318 }320 }
319 }321 }
320- needsWarm = current && p.client == nil && p.warm == nil322+ needsWarm = current && len(p.attachments) == 0 && p.warm == nil
321 return current323 return current
322 })324 })
323 if needsWarm {325 if needsWarm {
M internal/usecase/daemon/proxy_link.go +6 -4
@@ -659,10 +659,12 @@ func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out p
659659 if p == nil || !p.currentLinkGeneration(generation) {
660660 return nil
661661 }
662- p.sessionCore.mu.Lock()
663- ac := p.client
664- p.sessionCore.mu.Unlock()
665- if ac == nil || ac.output == nil {
662+ attachments := snapshotAttachmentSession(p)
663+ if len(attachments) == 0 {
664+ return nil
665+ }
666+ ac := attachments[0]
667+ if ac.output == nil {
666668 return nil
667669 }
668670 expected := ac.transportSnapshot()
@@ -659,10 +659,12 @@ func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out p
659 if p == nil || !p.currentLinkGeneration(generation) {659 if p == nil || !p.currentLinkGeneration(generation) {
660 return nil660 return nil
661 }661 }
662- p.sessionCore.mu.Lock()662+ attachments := snapshotAttachmentSession(p)
663- ac := p.client663+ if len(attachments) == 0 {
664- p.sessionCore.mu.Unlock()664+ return nil
665- if ac == nil || ac.output == nil {665+ }
666+ ac := attachments[0]
667+ if ac.output == nil {
666 return nil668 return nil
667 }669 }
668 expected := ac.transportSnapshot()670 expected := ac.transportSnapshot()
M internal/usecase/daemon/proxy_output.go +3 -4
@@ -91,10 +91,9 @@ func (d *Daemon) handleProxyScreenUpdate(p *proxySession, generation uint64, upd
9191 return nil
9292 }
9393
94- p.sessionCore.mu.Lock()
95- ac := p.client
96- p.sessionCore.mu.Unlock()
97- d.invalidateRender(p, ac, false, "proxy_output.go")
94+ for _, ac := range snapshotAttachmentSession(p) {
95+ d.invalidateRender(p, ac, false, "proxy_output.go")
96+ }
9897 return nil
9998 }
10099
@@ -91,10 +91,9 @@ func (d *Daemon) handleProxyScreenUpdate(p *proxySession, generation uint64, upd
91 return nil91 return nil
92 }92 }
93 93
94- p.sessionCore.mu.Lock()94+ for _, ac := range snapshotAttachmentSession(p) {
95- ac := p.client95+ d.invalidateRender(p, ac, false, "proxy_output.go")
96- p.sessionCore.mu.Unlock()96+ }
97- d.invalidateRender(p, ac, false, "proxy_output.go")
98 return nil97 return nil
99 }98 }
100 99
M internal/usecase/daemon/proxy_session.go +2 -5
@@ -122,10 +122,7 @@ func (d *Daemon) applyProxySessionMeta(p *proxySession, generation uint64, meta
122122 p.mu.Unlock()
123123
124124 d.pokeAttentionTicker()
125- p.sessionCore.mu.Lock()
126- ac := p.client
127- p.sessionCore.mu.Unlock()
128- if ac != nil {
125+ for _, ac := range snapshotAttachmentSession(p) {
129126 d.invalidateRender(p, ac, false, "proxy_session.go")
130127 }
131128 return true, nil
@@ -156,7 +153,7 @@ func (p *proxySession) snapshotView(opts viewOptions) sessionView {
156153 cannotAcceptMoves: true,
157154 }
158155 p.sessionCore.mu.Lock()
159- view.attached = p.client != nil
156+ view.attached = len(p.attachments) != 0
160157 p.sessionCore.mu.Unlock()
161158 if opts.tabDetails {
162159 view.tabs = make([]tabView, len(meta.Tabs))
@@ -122,10 +122,7 @@ func (d *Daemon) applyProxySessionMeta(p *proxySession, generation uint64, meta
122 p.mu.Unlock()122 p.mu.Unlock()
123 123
124 d.pokeAttentionTicker()124 d.pokeAttentionTicker()
125- p.sessionCore.mu.Lock()125+ for _, ac := range snapshotAttachmentSession(p) {
126- ac := p.client
127- p.sessionCore.mu.Unlock()
128- if ac != nil {
129 d.invalidateRender(p, ac, false, "proxy_session.go")126 d.invalidateRender(p, ac, false, "proxy_session.go")
130 }127 }
131 return true, nil128 return true, nil
@@ -156,7 +153,7 @@ func (p *proxySession) snapshotView(opts viewOptions) sessionView {
156 cannotAcceptMoves: true,153 cannotAcceptMoves: true,
157 }154 }
158 p.sessionCore.mu.Lock()155 p.sessionCore.mu.Lock()
159- view.attached = p.client != nil156+ view.attached = len(p.attachments) != 0
160 p.sessionCore.mu.Unlock()157 p.sessionCore.mu.Unlock()
161 if opts.tabDetails {158 if opts.tabDetails {
162 view.tabs = make([]tabView, len(meta.Tabs))159 view.tabs = make([]tabView, len(meta.Tabs))
M internal/usecase/daemon/render.go +16 -17
@@ -26,8 +26,8 @@ func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool {
2626 return false
2727 }
2828 sess.mu.Lock()
29- active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
30- attached := sess.client != nil
29+ active := sess.attachmentViewsTabLocked(tb)
30+ attached := len(sess.attachments) != 0
3131 sess.mu.Unlock()
3232
3333 // The normal attached render path needs no cross-session picker lookup.
@@ -63,17 +63,16 @@ func (d *Daemon) tabIsPickerPreview(tb *tab) bool {
6363 sessions := localSessionsSnapshot(d.sessions)
6464 d.mu.Unlock()
6565 for _, sess := range sessions {
66- sess.mu.Lock()
67- ac := sess.client
68- sess.mu.Unlock()
69- if ac == nil || ac.overlays == nil {
70- continue
71- }
72- ac.overlays.pickerMu.Lock()
73- preview := ac.overlays.pickerPreview == tb
74- ac.overlays.pickerMu.Unlock()
75- if preview {
76- return true
66+ for _, ac := range sess.snapshotAttachments() {
67+ if ac == nil || ac.overlays == nil {
68+ continue
69+ }
70+ ac.overlays.pickerMu.Lock()
71+ preview := ac.overlays.pickerPreview == tb
72+ ac.overlays.pickerMu.Unlock()
73+ if preview {
74+ return true
75+ }
7776 }
7877 }
7978 return false
@@ -320,7 +319,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
320319 }
321320 var tb *tab
322321 if local {
323- tb = sess.activeTab()
322+ tb = sess.tabForAttachmentOrActive(ac)
324323 if tb == nil {
325324 return
326325 }
@@ -331,7 +330,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
331330 // published identity while holding it so a deadline captured before an
332331 // attach/replace cannot emit on either the old or new output chain.
333332 entry.core().mu.Lock()
334- owned := entry.core().client == ac
333+ _, owned := entry.core().attachments[ac]
335334 entry.core().mu.Unlock()
336335 if !owned || ac.currentAttachmentSession() != entry {
337336 ac.sendMu.Unlock()
@@ -382,12 +381,12 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
382381 if statusFeedback == "" && overlays.resizeActive {
383382 statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit"
384383 }
385- bars := d.barStateForAttachmentPaletteHints(entry, statusFeedback, overlays.paletteHints, overlays.paletteRecent)
384+ bars := d.barStateForAttachmentPaletteHintsFor(entry, ac, statusFeedback, overlays.paletteHints, overlays.paletteRecent)
386385 applied := ac.getAppliedTheme()
387386 bars.theme = applied.Raw
388387 if local {
389388 attentionVisible := pulseVisible(bars.attentionFrame)
390- repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
389+ repaintAttachedClients = sess.ackAttention(tb, ac, attentionVisible)
391390 }
392391
393392 paletteCfg := d.currentPaletteConfig()
@@ -26,8 +26,8 @@ func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool {
26 return false26 return false
27 }27 }
28 sess.mu.Lock()28 sess.mu.Lock()
29- active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb29+ active := sess.attachmentViewsTabLocked(tb)
30- attached := sess.client != nil30+ attached := len(sess.attachments) != 0
31 sess.mu.Unlock()31 sess.mu.Unlock()
32 32
33 // The normal attached render path needs no cross-session picker lookup.33 // The normal attached render path needs no cross-session picker lookup.
@@ -63,17 +63,16 @@ func (d *Daemon) tabIsPickerPreview(tb *tab) bool {
63 sessions := localSessionsSnapshot(d.sessions)63 sessions := localSessionsSnapshot(d.sessions)
64 d.mu.Unlock()64 d.mu.Unlock()
65 for _, sess := range sessions {65 for _, sess := range sessions {
66- sess.mu.Lock()66+ for _, ac := range sess.snapshotAttachments() {
67- ac := sess.client67+ if ac == nil || ac.overlays == nil {
68- sess.mu.Unlock()68+ continue
69- if ac == nil || ac.overlays == nil {69+ }
70- continue70+ ac.overlays.pickerMu.Lock()
71- }71+ preview := ac.overlays.pickerPreview == tb
72- ac.overlays.pickerMu.Lock()72+ ac.overlays.pickerMu.Unlock()
73- preview := ac.overlays.pickerPreview == tb73+ if preview {
74- ac.overlays.pickerMu.Unlock()74+ return true
75- if preview {75+ }
76- return true
77 }76 }
78 }77 }
79 return false78 return false
@@ -320,7 +319,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
320 }319 }
321 var tb *tab320 var tb *tab
322 if local {321 if local {
323- tb = sess.activeTab()322+ tb = sess.tabForAttachmentOrActive(ac)
324 if tb == nil {323 if tb == nil {
325 return324 return
326 }325 }
@@ -331,7 +330,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
331 // published identity while holding it so a deadline captured before an330 // published identity while holding it so a deadline captured before an
332 // attach/replace cannot emit on either the old or new output chain.331 // attach/replace cannot emit on either the old or new output chain.
333 entry.core().mu.Lock()332 entry.core().mu.Lock()
334- owned := entry.core().client == ac333+ _, owned := entry.core().attachments[ac]
335 entry.core().mu.Unlock()334 entry.core().mu.Unlock()
336 if !owned || ac.currentAttachmentSession() != entry {335 if !owned || ac.currentAttachmentSession() != entry {
337 ac.sendMu.Unlock()336 ac.sendMu.Unlock()
@@ -382,12 +381,12 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
382 if statusFeedback == "" && overlays.resizeActive {381 if statusFeedback == "" && overlays.resizeActive {
383 statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit"382 statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit"
384 }383 }
385- bars := d.barStateForAttachmentPaletteHints(entry, statusFeedback, overlays.paletteHints, overlays.paletteRecent)384+ bars := d.barStateForAttachmentPaletteHintsFor(entry, ac, statusFeedback, overlays.paletteHints, overlays.paletteRecent)
386 applied := ac.getAppliedTheme()385 applied := ac.getAppliedTheme()
387 bars.theme = applied.Raw386 bars.theme = applied.Raw
388 if local {387 if local {
389 attentionVisible := pulseVisible(bars.attentionFrame)388 attentionVisible := pulseVisible(bars.attentionFrame)
390- repaintAttachedClients = sess.ackAttention(tb, attentionVisible)389+ repaintAttachedClients = sess.ackAttention(tb, ac, attentionVisible)
391 }390 }
392 391
393 paletteCfg := d.currentPaletteConfig()392 paletteCfg := d.currentPaletteConfig()
M internal/usecase/daemon/render_capture.go +2 -5
@@ -212,17 +212,14 @@ func captureLocalPrimaryRenderState(
212212 return nil, false
213213 }
214214 sess.mu.Lock()
215- owned := sess.client == ac
216- if !owned && sess.attachments != nil {
217- _, owned = sess.attachments[ac]
218- }
215+ _, owned := sess.attachments[ac]
219216 sess.mu.Unlock()
220217 if !owned {
221218 return nil, false
222219 }
223220 tb, _ := sess.paneForAttachment(ac)
224221 if tb == nil {
225- tb = sess.activeTab()
222+ tb = sess.tabForAttachmentOrActive(ac)
226223 }
227224 if tb == nil {
228225 return nil, false
@@ -212,17 +212,14 @@ func captureLocalPrimaryRenderState(
212 return nil, false212 return nil, false
213 }213 }
214 sess.mu.Lock()214 sess.mu.Lock()
215- owned := sess.client == ac215+ _, owned := sess.attachments[ac]
216- if !owned && sess.attachments != nil {
217- _, owned = sess.attachments[ac]
218- }
219 sess.mu.Unlock()216 sess.mu.Unlock()
220 if !owned {217 if !owned {
221 return nil, false218 return nil, false
222 }219 }
223 tb, _ := sess.paneForAttachment(ac)220 tb, _ := sess.paneForAttachment(ac)
224 if tb == nil {221 if tb == nil {
225- tb = sess.activeTab()222+ tb = sess.tabForAttachmentOrActive(ac)
226 }223 }
227 if tb == nil {224 if tb == nil {
228 return nil, false225 return nil, false
M internal/usecase/daemon/render_coordinator.go +15 -3
@@ -747,7 +747,14 @@ func (c *renderCoordinator) waitForTimerWorkers() { c.supervisor.wait() }
747747 // compositor; attached sessions always schedule through their coordinator.
748748 func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, reset bool, producer string) {
749749 if rc := attachmentRenderCoordinator(entry); rc != nil {
750- rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})
750+ if rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}) {
751+ return
752+ }
753+ // Secondary attachments do not borrow the coordinator's primary lease;
754+ // paint their own attachment-owned view after membership validation.
755+ if ac != nil && attachmentRegistered(entry, ac) && ac.currentAttachmentSession() == entry {
756+ d.paint(entry, ac, reset, nil)
757+ }
751758 return
752759 }
753760 if ac != nil {
@@ -760,8 +767,13 @@ func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, r
760767 // the required first full frame never depends on a debounce timer.
761768 func (d *Daemon) invalidateRenderNow(entry attachmentSession, ac *attachedClient, reset bool, producer string) {
762769 if rc := attachmentRenderCoordinator(entry); rc != nil {
763- rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})
764- rc.fireCurrent(false)
770+ if rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}) {
771+ rc.fireCurrent(false)
772+ return
773+ }
774+ if ac != nil && attachmentRegistered(entry, ac) && ac.currentAttachmentSession() == entry {
775+ d.paint(entry, ac, reset, nil)
776+ }
765777 return
766778 }
767779 if ac != nil {
@@ -747,7 +747,14 @@ func (c *renderCoordinator) waitForTimerWorkers() { c.supervisor.wait() }
747 // compositor; attached sessions always schedule through their coordinator.747 // compositor; attached sessions always schedule through their coordinator.
748 func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, reset bool, producer string) {748 func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, reset bool, producer string) {
749 if rc := attachmentRenderCoordinator(entry); rc != nil {749 if rc := attachmentRenderCoordinator(entry); rc != nil {
750- rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})750+ if rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}) {
751+ return
752+ }
753+ // Secondary attachments do not borrow the coordinator's primary lease;
754+ // paint their own attachment-owned view after membership validation.
755+ if ac != nil && attachmentRegistered(entry, ac) && ac.currentAttachmentSession() == entry {
756+ d.paint(entry, ac, reset, nil)
757+ }
751 return758 return
752 }759 }
753 if ac != nil {760 if ac != nil {
@@ -760,8 +767,13 @@ func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, r
760 // the required first full frame never depends on a debounce timer.767 // the required first full frame never depends on a debounce timer.
761 func (d *Daemon) invalidateRenderNow(entry attachmentSession, ac *attachedClient, reset bool, producer string) {768 func (d *Daemon) invalidateRenderNow(entry attachmentSession, ac *attachedClient, reset bool, producer string) {
762 if rc := attachmentRenderCoordinator(entry); rc != nil {769 if rc := attachmentRenderCoordinator(entry); rc != nil {
763- rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})770+ if rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}) {
764- rc.fireCurrent(false)771+ rc.fireCurrent(false)
772+ return
773+ }
774+ if ac != nil && attachmentRegistered(entry, ac) && ac.currentAttachmentSession() == entry {
775+ d.paint(entry, ac, reset, nil)
776+ }
765 return777 return
766 }778 }
767 if ac != nil {779 if ac != nil {
M internal/usecase/daemon/render_coordinator_types.go +2 -2
@@ -52,8 +52,8 @@ type renderWake struct {
5252 urgent bool
5353 coalesced int
5454 // lease is snapshotted under coordinator ownership. The wake consumer must
55- // compose only for this exact active attachment incarnation, never by
56- // rereading session.client after an attach publication.
55+ // compose only for this exact attachment incarnation, never by rereading
56+ // mutable session membership after attachment publication.
5757 lease *attachmentLease
5858 // watchdog marks a flush forced by the synchronized-output watchdog.
5959 watchdog bool
@@ -52,8 +52,8 @@ type renderWake struct {
52 urgent bool52 urgent bool
53 coalesced int53 coalesced int
54 // lease is snapshotted under coordinator ownership. The wake consumer must54 // lease is snapshotted under coordinator ownership. The wake consumer must
55- // compose only for this exact active attachment incarnation, never by55+ // compose only for this exact attachment incarnation, never by rereading
56- // rereading session.client after an attach publication.56+ // mutable session membership after attachment publication.
57 lease *attachmentLease57 lease *attachmentLease
58 // watchdog marks a flush forced by the synchronized-output watchdog.58 // watchdog marks a flush forced by the synchronized-output watchdog.
59 watchdog bool59 watchdog bool
M internal/usecase/daemon/render_pipeline.go +1 -1
@@ -459,7 +459,7 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
459459 return false
460460 }
461461 entry.core().mu.Lock()
462- owned := entry.core().client == ac
462+ _, owned := entry.core().attachments[ac]
463463 entry.core().mu.Unlock()
464464 if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry {
465465 ac.sendMu.Unlock()
@@ -459,7 +459,7 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
459 return false459 return false
460 }460 }
461 entry.core().mu.Lock()461 entry.core().mu.Lock()
462- owned := entry.core().client == ac462+ _, owned := entry.core().attachments[ac]
463 entry.core().mu.Unlock()463 entry.core().mu.Unlock()
464 if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry {464 if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry {
465 ac.sendMu.Unlock()465 ac.sendMu.Unlock()
M internal/usecase/daemon/resize_mode.go +2 -2
@@ -47,7 +47,7 @@ func (d *Daemon) enterResizeMode(sess *session, ac *attachedClient) error {
4747 if sess == nil || ac == nil {
4848 return layout.ErrNotFound
4949 }
50- target := resolveDaemonActionTarget(sess)
50+ target := resolveDaemonActionTargetForAttachment(sess, ac)
5151 if target.tab == nil || target.pane == nil {
5252 return layout.ErrNotFound
5353 }
@@ -131,7 +131,7 @@ func (d *Daemon) handleResizeInput(ac *attachedClient, data []byte) {
131131 return
132132 }
133133 for _, request := range requests {
134- request.target = resolveDaemonActionTarget(sess)
134+ request.target = resolveDaemonActionTargetForAttachment(sess, ac)
135135 if err := (daemonActions{d: d}).Run(request); err != nil {
136136 d.reportError(sess, resizeUserError(err))
137137 continue
@@ -47,7 +47,7 @@ func (d *Daemon) enterResizeMode(sess *session, ac *attachedClient) error {
47 if sess == nil || ac == nil {47 if sess == nil || ac == nil {
48 return layout.ErrNotFound48 return layout.ErrNotFound
49 }49 }
50- target := resolveDaemonActionTarget(sess)50+ target := resolveDaemonActionTargetForAttachment(sess, ac)
51 if target.tab == nil || target.pane == nil {51 if target.tab == nil || target.pane == nil {
52 return layout.ErrNotFound52 return layout.ErrNotFound
53 }53 }
@@ -131,7 +131,7 @@ func (d *Daemon) handleResizeInput(ac *attachedClient, data []byte) {
131 return131 return
132 }132 }
133 for _, request := range requests {133 for _, request := range requests {
134- request.target = resolveDaemonActionTarget(sess)134+ request.target = resolveDaemonActionTargetForAttachment(sess, ac)
135 if err := (daemonActions{d: d}).Run(request); err != nil {135 if err := (daemonActions{d: d}).Run(request); err != nil {
136 d.reportError(sess, resizeUserError(err))136 d.reportError(sess, resizeUserError(err))
137 continue137 continue
M internal/usecase/daemon/resume.go +12 -15
@@ -37,8 +37,8 @@ func (d *Daemon) nextResumeTokenLocked() uint64 {
3737 // can wait out the detach→park gap. Callers must publish this before clearing
3838 // the live seat so resume never observes both registries empty for a still-valid
3939 // credential. Under d.mu then sess.mu it advertises only when the daemon still
40-// registers the exact session and ac is still the exact active or snatched
41-// owner; otherwise it returns 0 and never creates a marker after terminal
40+// registers the exact session and ac remains a registered attachment; otherwise
41+// it returns 0 and never creates a marker after terminal
4242 // cleanup. Returns the resume token, or 0 when parking cannot be advertised.
4343 func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 {
4444 if sess == nil || ac == nil || !ac.resumeCapable {
@@ -50,10 +50,9 @@ func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 {
5050 return 0
5151 }
5252 sess.mu.Lock()
53- role := sess.attachmentRoleLocked(ac)
54- owner := role == attachmentActive || role == attachmentSnatched
53+ registered := attachmentRegisteredLocked(sess, ac)
5554 sess.mu.Unlock()
56- if !owner {
55+ if !registered {
5756 return 0
5857 }
5958 if ac.resumeToken == 0 {
@@ -96,8 +95,8 @@ func (d *Daemon) clearParkingInFlight(token uint64, ac *attachedClient) {
9695 }
9796
9897 // clearParkingInFlightIfAbandoned drops a pre-detach parking marker when this
99-// teardown lost the seat while still the live owner (stale transport fence for
100-// active or snatched). If another path already detached/unrouted, that path
98+// teardown lost the seat while still the live owner (stale transport fence).
99+// If another path already detached/unrouted, that path
101100 // owns park publication.
102101 func (d *Daemon) clearParkingInFlightIfAbandoned(sess *session, ac *attachedClient, token uint64) {
103102 if token == 0 || ac == nil {
@@ -347,9 +346,6 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
347346 sess := parked.sess
348347 if sess != nil {
349348 sess.mu.Lock()
350- if sess.client == ac {
351- sess.client = nil
352- }
353349 sess.unregisterAttachmentLocked(ac)
354350 sess.mu.Unlock()
355351 }
@@ -442,11 +438,11 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
442438 )
443439 if sess != nil {
444440 sess.mu.Lock()
445- ac = sess.client
446- // resumeToken writes run under d.mu; read the live credential here
447- // before releasing the daemon lock, then revalidate via the exact
448- // transport incarnation through detachIfCurrentTransport.
449- if ac != nil && ac.resumeCapable && ac.resumeToken == h.ResumeToken {
441+ for candidate := range sess.attachments {
442+ if !candidate.resumeCapable || candidate.resumeToken != h.ResumeToken {
443+ continue
444+ }
445+ ac = candidate
450446 if ac.clientID != h.ClientID {
451447 clientMismatch = true
452448 } else if ac.proxied != h.Proxied {
@@ -455,6 +451,7 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
455451 oldSnap = ac.transportSnapshot()
456452 credentialMatch = oldSnap.transport != nil
457453 }
454+ break
458455 }
459456 sess.mu.Unlock()
460457 }
@@ -37,8 +37,8 @@ func (d *Daemon) nextResumeTokenLocked() uint64 {
37 // can wait out the detach→park gap. Callers must publish this before clearing37 // can wait out the detach→park gap. Callers must publish this before clearing
38 // the live seat so resume never observes both registries empty for a still-valid38 // the live seat so resume never observes both registries empty for a still-valid
39 // credential. Under d.mu then sess.mu it advertises only when the daemon still39 // credential. Under d.mu then sess.mu it advertises only when the daemon still
40-// registers the exact session and ac is still the exact active or snatched40+// registers the exact session and ac remains a registered attachment; otherwise
41-// owner; otherwise it returns 0 and never creates a marker after terminal41+// it returns 0 and never creates a marker after terminal
42 // cleanup. Returns the resume token, or 0 when parking cannot be advertised.42 // cleanup. Returns the resume token, or 0 when parking cannot be advertised.
43 func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 {43 func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 {
44 if sess == nil || ac == nil || !ac.resumeCapable {44 if sess == nil || ac == nil || !ac.resumeCapable {
@@ -50,10 +50,9 @@ func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 {
50 return 050 return 0
51 }51 }
52 sess.mu.Lock()52 sess.mu.Lock()
53- role := sess.attachmentRoleLocked(ac)53+ registered := attachmentRegisteredLocked(sess, ac)
54- owner := role == attachmentActive || role == attachmentSnatched
55 sess.mu.Unlock()54 sess.mu.Unlock()
56- if !owner {55+ if !registered {
57 return 056 return 0
58 }57 }
59 if ac.resumeToken == 0 {58 if ac.resumeToken == 0 {
@@ -96,8 +95,8 @@ func (d *Daemon) clearParkingInFlight(token uint64, ac *attachedClient) {
96 }95 }
97 96
98 // clearParkingInFlightIfAbandoned drops a pre-detach parking marker when this97 // clearParkingInFlightIfAbandoned drops a pre-detach parking marker when this
99-// teardown lost the seat while still the live owner (stale transport fence for98+// teardown lost the seat while still the live owner (stale transport fence).
100-// active or snatched). If another path already detached/unrouted, that path99+// If another path already detached/unrouted, that path
101 // owns park publication.100 // owns park publication.
102 func (d *Daemon) clearParkingInFlightIfAbandoned(sess *session, ac *attachedClient, token uint64) {101 func (d *Daemon) clearParkingInFlightIfAbandoned(sess *session, ac *attachedClient, token uint64) {
103 if token == 0 || ac == nil {102 if token == 0 || ac == nil {
@@ -347,9 +346,6 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
347 sess := parked.sess346 sess := parked.sess
348 if sess != nil {347 if sess != nil {
349 sess.mu.Lock()348 sess.mu.Lock()
350- if sess.client == ac {
351- sess.client = nil
352- }
353 sess.unregisterAttachmentLocked(ac)349 sess.unregisterAttachmentLocked(ac)
354 sess.mu.Unlock()350 sess.mu.Unlock()
355 }351 }
@@ -442,11 +438,11 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
442 )438 )
443 if sess != nil {439 if sess != nil {
444 sess.mu.Lock()440 sess.mu.Lock()
445- ac = sess.client441+ for candidate := range sess.attachments {
446- // resumeToken writes run under d.mu; read the live credential here442+ if !candidate.resumeCapable || candidate.resumeToken != h.ResumeToken {
447- // before releasing the daemon lock, then revalidate via the exact443+ continue
448- // transport incarnation through detachIfCurrentTransport.444+ }
449- if ac != nil && ac.resumeCapable && ac.resumeToken == h.ResumeToken {445+ ac = candidate
450 if ac.clientID != h.ClientID {446 if ac.clientID != h.ClientID {
451 clientMismatch = true447 clientMismatch = true
452 } else if ac.proxied != h.Proxied {448 } else if ac.proxied != h.Proxied {
@@ -455,6 +451,7 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
455 oldSnap = ac.transportSnapshot()451 oldSnap = ac.transportSnapshot()
456 credentialMatch = oldSnap.transport != nil452 credentialMatch = oldSnap.transport != nil
457 }453 }
454+ break
458 }455 }
459 sess.mu.Unlock()456 sess.mu.Unlock()
460 }457 }
M internal/usecase/daemon/session.go +36 -90
@@ -416,12 +416,12 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
416416 cwd := from.cwd
417417 term := from.terminal
418418 env := copyEnvironment(from.env)
419- if from.client != ac {
420- from.mu.Unlock()
419+ _, attached := from.attachments[ac]
420+ from.mu.Unlock()
421+ if !attached {
421422 d.mu.Unlock()
422423 return errors.New("client detached")
423424 }
424- from.mu.Unlock()
425425
426426 newSess, err := d.createSessionLockedWithMode(name, false, cwd, sz, term, env, ac.proxied)
427427 d.mu.Unlock()
@@ -514,11 +514,12 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
514514 sess.mu.Lock()
515515 name := sess.name
516516 cwd := sess.cwd
517- client := sess.client
518517 term := sess.terminal
519518 env := copyEnvironment(sess.env)
519+ attachments := sess.snapshotAttachmentsLocked()
520520 sess.mu.Unlock()
521- tbSize := contentSize(sz, client != nil && client.proxied)
521+ proxied := len(attachments) != 0 && attachments[0].proxied
522+ tbSize := contentSize(sz, proxied)
522523 tabStableID, paneStableID, err := d.newTabPaneStableIDs()
523524 if err != nil {
524525 return err
@@ -535,8 +536,8 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
535536 return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err)
536537 }
537538 tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
538- if client != nil {
539- t := d.effectiveTheme(client.getClientTheme())
539+ if len(attachments) != 0 {
540+ t := d.effectiveTheme(attachments[0].getClientTheme())
540541 tb.mu.Lock()
541542 p := tb.focusedPane()
542543 tb.mu.Unlock()
@@ -566,8 +567,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
566567 // Publish ownership before appending the tab to the visible session.
567568 publishPaneOwner(p, sess, tb, 0)
568569 sess.tabs = append(sess.tabs, tb)
569- sess.active = len(sess.tabs) - 1
570- tabIndex := sess.active
570+ tabIndex := len(sess.tabs) - 1
571571 if !sess.ephemeral {
572572 d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
573573 }
@@ -661,8 +661,8 @@ func contentSize(clientSize domain.Size, proxied bool) domain.Size {
661661 func (s *session) fullViewportSize() domain.Size {
662662 s.mu.Lock()
663663 var tb *tab
664- if s.active >= 0 && s.active < len(s.tabs) {
665- tb = s.tabs[s.active]
664+ if len(s.tabs) != 0 {
665+ tb = s.tabs[0]
666666 }
667667 s.mu.Unlock()
668668
@@ -715,13 +715,12 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
715715 func (s *session) detachIfCurrent(ac *attachedClient) bool {
716716 s.mu.Lock()
717717 defer s.mu.Unlock()
718- if s.client == ac {
719- s.client = nil
720- s.unregisterAttachmentLocked(ac)
721- ac.setSession(nil)
722- return true
718+ if !attachmentRegisteredLocked(s, ac) || ac.currentAttachmentSession() != s {
719+ return false
723720 }
724- return false
721+ s.unregisterAttachmentLocked(ac)
722+ ac.setSession(nil)
723+ return true
725724 }
726725
727726 // detachIfCurrent publishes terminal role invalidation through the attachment
@@ -744,22 +743,12 @@ func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, exp
744743 }
745744 d.notices.routingMu.Lock()
746745 sess.mu.Lock()
747- _, registered := sess.attachments[ac]
748- current := (registered || sess.client == ac) && ac.currentSession() == sess
746+ current := attachmentRegisteredLocked(sess, ac) && ac.currentAttachmentSession() == sess
749747 if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) {
750748 current = false
751749 }
752750 if current {
753- if sess.client == ac {
754- sess.client = nil
755- }
756751 sess.unregisterAttachmentLocked(ac)
757- if sess.client == nil {
758- for candidate := range sess.attachments {
759- sess.client = candidate
760- break
761- }
762- }
763752 ac.roleGeneration.Add(1)
764753 ac.setSession(nil)
765754 ac.invalidateFrozenRoleCapability()
@@ -784,12 +773,11 @@ func (d *Daemon) detachProxyIfCurrentTransport(p *proxySession, ac *attachedClie
784773 }
785774 d.notices.routingMu.Lock()
786775 p.sessionCore.mu.Lock()
787- current := p.client == ac && ac.currentAttachmentSession() == attachmentSession(p)
776+ current := attachmentRegisteredLocked(attachmentSession(p), ac) && ac.currentAttachmentSession() == attachmentSession(p)
788777 if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) {
789778 current = false
790779 }
791780 if current {
792- p.client = nil
793781 unregisterAttachmentSessionLocked(attachmentSession(p), ac)
794782 ac.roleGeneration.Add(1)
795783 ac.setSession(nil)
@@ -852,7 +840,6 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func()
852840 }
853841 current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
854842 if current {
855- core.client = nil
856843 unregisterAttachmentSessionLocked(token.sess, token.ac)
857844 token.ac.roleGeneration.Add(1)
858845 token.ac.setSession(nil)
@@ -888,18 +875,6 @@ func (s *session) switchTab(idx int) bool {
888875 return true
889876 }
890877
891-func (s *session) switchRelative(delta int) bool {
892- s.mu.Lock()
893- if len(s.tabs) < 2 {
894- s.mu.Unlock()
895- return false
896- }
897- s.active = (s.active + delta + len(s.tabs)) % len(s.tabs)
898- s.mu.Unlock()
899- markSnapshotDirty(s)
900- return true
901-}
902-
903878 func (d *Daemon) renameSession(sess *session, name string) error {
904879 if name == "" {
905880 return errSessionNameRequired
@@ -1068,15 +1043,8 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
10681043 return nil
10691044 }
10701045 ringing := tb.attention
1071- wasActive := idx == sess.active
10721046 sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)
1073- if sess.active >= len(sess.tabs) {
1074- sess.active = len(sess.tabs) - 1
1075- } else if idx < sess.active {
1076- sess.active--
1077- }
1078- destination := sess.tabs[sess.active]
1079- ac := sess.client
1047+ attachments := sess.snapshotAttachmentsLocked()
10801048 name := sess.name
10811049 if !sess.ephemeral {
10821050 d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
@@ -1090,24 +1058,25 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
10901058 tb.mu.Lock()
10911059 panes := tb.panesSnapshot()
10921060 tb.mu.Unlock()
1093- if ac != nil {
1061+ for _, ac := range attachments {
10941062 ac.pruneCaptureFrames(panes...)
10951063 }
10961064 if tb.cancel != nil {
10971065 tb.cancel()
10981066 }
1099- d.teardownFloating(tb, ac)
1067+ for _, ac := range attachments {
1068+ d.teardownFloating(tb, ac)
1069+ }
11001070 if rc := sess.renderCoordinator(); rc != nil {
11011071 for _, p := range panes {
11021072 rc.noteSyncPaneRemoved(p)
11031073 }
11041074 }
11051075 tb.closeAllPanes()
1106- if wasActive {
1107- d.activateTab(sess, destination)
1108- }
1109- if repaint && ac != nil {
1110- d.invalidateRender(sess, ac, true, "session.go")
1076+ if repaint {
1077+ for _, ac := range attachments {
1078+ d.invalidateRender(sess, ac, true, "session.go")
1079+ }
11111080 }
11121081 if ringing {
11131082 d.repaintAllAttachedClients()
@@ -1190,9 +1159,7 @@ type sessionKillAdmission struct {
11901159 }
11911160
11921161 type sessionKillParticipants struct {
1193- active *attachedClient
11941162 attachments map[*attachedClient]struct{}
1195- snatched map[*attachedClient]struct{}
11961163 transports map[*attachedClient]transportSnapshot
11971164 roleGates []*attachedClient
11981165 interrupts []roleTransportInterrupt
@@ -1235,26 +1202,18 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
12351202 source = target
12361203 }
12371204 unlockSessions := lockAttachmentSessions(source, target)
1238- snapshot.active = target.client
12391205 snapshot.attachments = make(map[*attachedClient]struct{}, len(target.attachments))
1240- snapshot.snatched = make(map[*attachedClient]struct{}, len(target.snatched))
1241- snapshot.transports = make(map[*attachedClient]transportSnapshot, 1+len(target.attachments)+len(target.snatched))
1206+ snapshot.transports = make(map[*attachedClient]transportSnapshot, len(target.attachments))
12421207 for ac := range target.attachments {
12431208 snapshot.attachments[ac] = struct{}{}
12441209 snapshot.transports[ac] = ac.transportSnapshot()
12451210 }
1246- if snapshot.active != nil {
1247- snapshot.transports[snapshot.active] = snapshot.active.transportSnapshot()
1248- }
1249- for ac := range target.snatched {
1250- snapshot.snatched[ac] = struct{}{}
1251- snapshot.transports[ac] = ac.transportSnapshot()
1252- }
1211+
12531212 unlockSessions()
12541213 d.notices.routingMu.Unlock()
12551214 d.mu.Unlock()
12561215
1257- seen := make(map[*attachedClient]struct{}, 2+len(snapshot.attachments)+len(snapshot.snatched))
1216+ seen := make(map[*attachedClient]struct{}, 1+len(snapshot.attachments))
12581217 add := func(ac *attachedClient) {
12591218 if ac == nil {
12601219 return
@@ -1268,13 +1227,9 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
12681227 if admission != nil {
12691228 add(admission.token.ac)
12701229 }
1271- add(snapshot.active)
12721230 for ac := range snapshot.attachments {
12731231 add(ac)
12741232 }
1275- for ac := range snapshot.snatched {
1276- add(ac)
1277- }
12781233 for ac, transport := range snapshot.transports {
12791234 if transport.transport != nil {
12801235 snapshot.interrupts = append(snapshot.interrupts, roleTransportInterrupt{ac: ac, transport: transport})
@@ -1311,7 +1266,7 @@ func (d *Daemon) sessionKillParticipantsCurrent(snapshot sessionKillParticipants
13111266 // session and coordinator locks.
13121267 func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sourceCoordinator *renderCoordinator) bool {
13131268 target := snapshot.target
1314- if target.client != snapshot.active || len(target.attachments) != len(snapshot.attachments) || len(target.snatched) != len(snapshot.snatched) {
1269+ if len(target.attachments) != len(snapshot.attachments) {
13151270 return false
13161271 }
13171272 for ac := range snapshot.attachments {
@@ -1319,11 +1274,7 @@ func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sour
13191274 return false
13201275 }
13211276 }
1322- for ac := range snapshot.snatched {
1323- if _, current := target.snatched[ac]; !current {
1324- return false
1325- }
1326- }
1277+
13271278 for ac, transport := range snapshot.transports {
13281279 if !ac.transportSnapshotCurrent(transport) {
13291280 return false
@@ -1617,7 +1568,6 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
16171568 d.clearBarScriptsForSession(sess.id)
16181569 d.purgeParkingForSessionLocked(sess)
16191570 parkedRetirements := d.purgeParkedForSessionLocked(sess)
1620- detachedActive := sess.client
16211571 attachments := make([]detachedAttachmentSnapshot, 0, len(participants.roleGates))
16221572 capture := func(attached *attachedClient) {
16231573 if attached == nil {
@@ -1631,13 +1581,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
16311581 for attached := range sess.attachments {
16321582 capture(attached)
16331583 }
1634- for snatched := range sess.snatched {
1635- capture(snatched)
1636- }
1637- capture(sess.client)
1638- sess.client = nil
16391584 clear(sess.attachments)
1640- clear(sess.snatched)
16411585 stoppedName := sess.name
16421586 stoppedCwd := sess.cwd
16431587 createdAt := sess.createdAt
@@ -1688,7 +1632,9 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
16881632 sess.mu.Unlock()
16891633 for _, tb := range tabs {
16901634 d.clearDestroyedTabPreview(tb)
1691- d.teardownFloating(tb, detachedActive)
1635+ for _, attachment := range attachments {
1636+ d.teardownFloating(tb, attachment.ac)
1637+ }
16921638 }
16931639 for _, path := range clipFiles {
16941640 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
@@ -1801,7 +1747,7 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
18011747 if d.procCwd == nil {
18021748 return
18031749 }
1804- tb := sess.activeTab()
1750+ tb := sess.tabForAttachmentOrActive(nil)
18051751 if tb == nil {
18061752 return
18071753 }
@@ -416,12 +416,12 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
416 cwd := from.cwd416 cwd := from.cwd
417 term := from.terminal417 term := from.terminal
418 env := copyEnvironment(from.env)418 env := copyEnvironment(from.env)
419- if from.client != ac {419+ _, attached := from.attachments[ac]
420- from.mu.Unlock()420+ from.mu.Unlock()
421+ if !attached {
421 d.mu.Unlock()422 d.mu.Unlock()
422 return errors.New("client detached")423 return errors.New("client detached")
423 }424 }
424- from.mu.Unlock()
425 425
426 newSess, err := d.createSessionLockedWithMode(name, false, cwd, sz, term, env, ac.proxied)426 newSess, err := d.createSessionLockedWithMode(name, false, cwd, sz, term, env, ac.proxied)
427 d.mu.Unlock()427 d.mu.Unlock()
@@ -514,11 +514,12 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
514 sess.mu.Lock()514 sess.mu.Lock()
515 name := sess.name515 name := sess.name
516 cwd := sess.cwd516 cwd := sess.cwd
517- client := sess.client
518 term := sess.terminal517 term := sess.terminal
519 env := copyEnvironment(sess.env)518 env := copyEnvironment(sess.env)
519+ attachments := sess.snapshotAttachmentsLocked()
520 sess.mu.Unlock()520 sess.mu.Unlock()
521- tbSize := contentSize(sz, client != nil && client.proxied)521+ proxied := len(attachments) != 0 && attachments[0].proxied
522+ tbSize := contentSize(sz, proxied)
522 tabStableID, paneStableID, err := d.newTabPaneStableIDs()523 tabStableID, paneStableID, err := d.newTabPaneStableIDs()
523 if err != nil {524 if err != nil {
524 return err525 return err
@@ -535,8 +536,8 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
535 return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err)536 return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err)
536 }537 }
537 tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)538 tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
538- if client != nil {539+ if len(attachments) != 0 {
539- t := d.effectiveTheme(client.getClientTheme())540+ t := d.effectiveTheme(attachments[0].getClientTheme())
540 tb.mu.Lock()541 tb.mu.Lock()
541 p := tb.focusedPane()542 p := tb.focusedPane()
542 tb.mu.Unlock()543 tb.mu.Unlock()
@@ -566,8 +567,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
566 // Publish ownership before appending the tab to the visible session.567 // Publish ownership before appending the tab to the visible session.
567 publishPaneOwner(p, sess, tb, 0)568 publishPaneOwner(p, sess, tb, 0)
568 sess.tabs = append(sess.tabs, tb)569 sess.tabs = append(sess.tabs, tb)
569- sess.active = len(sess.tabs) - 1570+ tabIndex := len(sess.tabs) - 1
570- tabIndex := sess.active
571 if !sess.ephemeral {571 if !sess.ephemeral {
572 d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())572 d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
573 }573 }
@@ -661,8 +661,8 @@ func contentSize(clientSize domain.Size, proxied bool) domain.Size {
661 func (s *session) fullViewportSize() domain.Size {661 func (s *session) fullViewportSize() domain.Size {
662 s.mu.Lock()662 s.mu.Lock()
663 var tb *tab663 var tb *tab
664- if s.active >= 0 && s.active < len(s.tabs) {664+ if len(s.tabs) != 0 {
665- tb = s.tabs[s.active]665+ tb = s.tabs[0]
666 }666 }
667 s.mu.Unlock()667 s.mu.Unlock()
668 668
@@ -715,13 +715,12 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
715 func (s *session) detachIfCurrent(ac *attachedClient) bool {715 func (s *session) detachIfCurrent(ac *attachedClient) bool {
716 s.mu.Lock()716 s.mu.Lock()
717 defer s.mu.Unlock()717 defer s.mu.Unlock()
718- if s.client == ac {718+ if !attachmentRegisteredLocked(s, ac) || ac.currentAttachmentSession() != s {
719- s.client = nil719+ return false
720- s.unregisterAttachmentLocked(ac)
721- ac.setSession(nil)
722- return true
723 }720 }
724- return false721+ s.unregisterAttachmentLocked(ac)
722+ ac.setSession(nil)
723+ return true
725 }724 }
726 725
727 // detachIfCurrent publishes terminal role invalidation through the attachment726 // detachIfCurrent publishes terminal role invalidation through the attachment
@@ -744,22 +743,12 @@ func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, exp
744 }743 }
745 d.notices.routingMu.Lock()744 d.notices.routingMu.Lock()
746 sess.mu.Lock()745 sess.mu.Lock()
747- _, registered := sess.attachments[ac]746+ current := attachmentRegisteredLocked(sess, ac) && ac.currentAttachmentSession() == sess
748- current := (registered || sess.client == ac) && ac.currentSession() == sess
749 if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) {747 if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) {
750 current = false748 current = false
751 }749 }
752 if current {750 if current {
753- if sess.client == ac {
754- sess.client = nil
755- }
756 sess.unregisterAttachmentLocked(ac)751 sess.unregisterAttachmentLocked(ac)
757- if sess.client == nil {
758- for candidate := range sess.attachments {
759- sess.client = candidate
760- break
761- }
762- }
763 ac.roleGeneration.Add(1)752 ac.roleGeneration.Add(1)
764 ac.setSession(nil)753 ac.setSession(nil)
765 ac.invalidateFrozenRoleCapability()754 ac.invalidateFrozenRoleCapability()
@@ -784,12 +773,11 @@ func (d *Daemon) detachProxyIfCurrentTransport(p *proxySession, ac *attachedClie
784 }773 }
785 d.notices.routingMu.Lock()774 d.notices.routingMu.Lock()
786 p.sessionCore.mu.Lock()775 p.sessionCore.mu.Lock()
787- current := p.client == ac && ac.currentAttachmentSession() == attachmentSession(p)776+ current := attachmentRegisteredLocked(attachmentSession(p), ac) && ac.currentAttachmentSession() == attachmentSession(p)
788 if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) {777 if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) {
789 current = false778 current = false
790 }779 }
791 if current {780 if current {
792- p.client = nil
793 unregisterAttachmentSessionLocked(attachmentSession(p), ac)781 unregisterAttachmentSessionLocked(attachmentSession(p), ac)
794 ac.roleGeneration.Add(1)782 ac.roleGeneration.Add(1)
795 ac.setSession(nil)783 ac.setSession(nil)
@@ -852,7 +840,6 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func()
852 }840 }
853 current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)841 current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
854 if current {842 if current {
855- core.client = nil
856 unregisterAttachmentSessionLocked(token.sess, token.ac)843 unregisterAttachmentSessionLocked(token.sess, token.ac)
857 token.ac.roleGeneration.Add(1)844 token.ac.roleGeneration.Add(1)
858 token.ac.setSession(nil)845 token.ac.setSession(nil)
@@ -888,18 +875,6 @@ func (s *session) switchTab(idx int) bool {
888 return true875 return true
889 }876 }
890 877
891-func (s *session) switchRelative(delta int) bool {
892- s.mu.Lock()
893- if len(s.tabs) < 2 {
894- s.mu.Unlock()
895- return false
896- }
897- s.active = (s.active + delta + len(s.tabs)) % len(s.tabs)
898- s.mu.Unlock()
899- markSnapshotDirty(s)
900- return true
901-}
902-
903 func (d *Daemon) renameSession(sess *session, name string) error {878 func (d *Daemon) renameSession(sess *session, name string) error {
904 if name == "" {879 if name == "" {
905 return errSessionNameRequired880 return errSessionNameRequired
@@ -1068,15 +1043,8 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
1068 return nil1043 return nil
1069 }1044 }
1070 ringing := tb.attention1045 ringing := tb.attention
1071- wasActive := idx == sess.active
1072 sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)1046 sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)
1073- if sess.active >= len(sess.tabs) {1047+ attachments := sess.snapshotAttachmentsLocked()
1074- sess.active = len(sess.tabs) - 1
1075- } else if idx < sess.active {
1076- sess.active--
1077- }
1078- destination := sess.tabs[sess.active]
1079- ac := sess.client
1080 name := sess.name1048 name := sess.name
1081 if !sess.ephemeral {1049 if !sess.ephemeral {
1082 d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())1050 d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
@@ -1090,24 +1058,25 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
1090 tb.mu.Lock()1058 tb.mu.Lock()
1091 panes := tb.panesSnapshot()1059 panes := tb.panesSnapshot()
1092 tb.mu.Unlock()1060 tb.mu.Unlock()
1093- if ac != nil {1061+ for _, ac := range attachments {
1094 ac.pruneCaptureFrames(panes...)1062 ac.pruneCaptureFrames(panes...)
1095 }1063 }
1096 if tb.cancel != nil {1064 if tb.cancel != nil {
1097 tb.cancel()1065 tb.cancel()
1098 }1066 }
1099- d.teardownFloating(tb, ac)1067+ for _, ac := range attachments {
1068+ d.teardownFloating(tb, ac)
1069+ }
1100 if rc := sess.renderCoordinator(); rc != nil {1070 if rc := sess.renderCoordinator(); rc != nil {
1101 for _, p := range panes {1071 for _, p := range panes {
1102 rc.noteSyncPaneRemoved(p)1072 rc.noteSyncPaneRemoved(p)
1103 }1073 }
1104 }1074 }
1105 tb.closeAllPanes()1075 tb.closeAllPanes()
1106- if wasActive {1076+ if repaint {
1107- d.activateTab(sess, destination)1077+ for _, ac := range attachments {
1108- }1078+ d.invalidateRender(sess, ac, true, "session.go")
1109- if repaint && ac != nil {1079+ }
1110- d.invalidateRender(sess, ac, true, "session.go")
1111 }1080 }
1112 if ringing {1081 if ringing {
1113 d.repaintAllAttachedClients()1082 d.repaintAllAttachedClients()
@@ -1190,9 +1159,7 @@ type sessionKillAdmission struct {
1190 }1159 }
1191 1160
1192 type sessionKillParticipants struct {1161 type sessionKillParticipants struct {
1193- active *attachedClient
1194 attachments map[*attachedClient]struct{}1162 attachments map[*attachedClient]struct{}
1195- snatched map[*attachedClient]struct{}
1196 transports map[*attachedClient]transportSnapshot1163 transports map[*attachedClient]transportSnapshot
1197 roleGates []*attachedClient1164 roleGates []*attachedClient
1198 interrupts []roleTransportInterrupt1165 interrupts []roleTransportInterrupt
@@ -1235,26 +1202,18 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
1235 source = target1202 source = target
1236 }1203 }
1237 unlockSessions := lockAttachmentSessions(source, target)1204 unlockSessions := lockAttachmentSessions(source, target)
1238- snapshot.active = target.client
1239 snapshot.attachments = make(map[*attachedClient]struct{}, len(target.attachments))1205 snapshot.attachments = make(map[*attachedClient]struct{}, len(target.attachments))
1240- snapshot.snatched = make(map[*attachedClient]struct{}, len(target.snatched))1206+ snapshot.transports = make(map[*attachedClient]transportSnapshot, len(target.attachments))
1241- snapshot.transports = make(map[*attachedClient]transportSnapshot, 1+len(target.attachments)+len(target.snatched))
1242 for ac := range target.attachments {1207 for ac := range target.attachments {
1243 snapshot.attachments[ac] = struct{}{}1208 snapshot.attachments[ac] = struct{}{}
1244 snapshot.transports[ac] = ac.transportSnapshot()1209 snapshot.transports[ac] = ac.transportSnapshot()
1245 }1210 }
1246- if snapshot.active != nil {1211+
1247- snapshot.transports[snapshot.active] = snapshot.active.transportSnapshot()
1248- }
1249- for ac := range target.snatched {
1250- snapshot.snatched[ac] = struct{}{}
1251- snapshot.transports[ac] = ac.transportSnapshot()
1252- }
1253 unlockSessions()1212 unlockSessions()
1254 d.notices.routingMu.Unlock()1213 d.notices.routingMu.Unlock()
1255 d.mu.Unlock()1214 d.mu.Unlock()
1256 1215
1257- seen := make(map[*attachedClient]struct{}, 2+len(snapshot.attachments)+len(snapshot.snatched))1216+ seen := make(map[*attachedClient]struct{}, 1+len(snapshot.attachments))
1258 add := func(ac *attachedClient) {1217 add := func(ac *attachedClient) {
1259 if ac == nil {1218 if ac == nil {
1260 return1219 return
@@ -1268,13 +1227,9 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
1268 if admission != nil {1227 if admission != nil {
1269 add(admission.token.ac)1228 add(admission.token.ac)
1270 }1229 }
1271- add(snapshot.active)
1272 for ac := range snapshot.attachments {1230 for ac := range snapshot.attachments {
1273 add(ac)1231 add(ac)
1274 }1232 }
1275- for ac := range snapshot.snatched {
1276- add(ac)
1277- }
1278 for ac, transport := range snapshot.transports {1233 for ac, transport := range snapshot.transports {
1279 if transport.transport != nil {1234 if transport.transport != nil {
1280 snapshot.interrupts = append(snapshot.interrupts, roleTransportInterrupt{ac: ac, transport: transport})1235 snapshot.interrupts = append(snapshot.interrupts, roleTransportInterrupt{ac: ac, transport: transport})
@@ -1311,7 +1266,7 @@ func (d *Daemon) sessionKillParticipantsCurrent(snapshot sessionKillParticipants
1311 // session and coordinator locks.1266 // session and coordinator locks.
1312 func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sourceCoordinator *renderCoordinator) bool {1267 func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sourceCoordinator *renderCoordinator) bool {
1313 target := snapshot.target1268 target := snapshot.target
1314- if target.client != snapshot.active || len(target.attachments) != len(snapshot.attachments) || len(target.snatched) != len(snapshot.snatched) {1269+ if len(target.attachments) != len(snapshot.attachments) {
1315 return false1270 return false
1316 }1271 }
1317 for ac := range snapshot.attachments {1272 for ac := range snapshot.attachments {
@@ -1319,11 +1274,7 @@ func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sour
1319 return false1274 return false
1320 }1275 }
1321 }1276 }
1322- for ac := range snapshot.snatched {1277+
1323- if _, current := target.snatched[ac]; !current {
1324- return false
1325- }
1326- }
1327 for ac, transport := range snapshot.transports {1278 for ac, transport := range snapshot.transports {
1328 if !ac.transportSnapshotCurrent(transport) {1279 if !ac.transportSnapshotCurrent(transport) {
1329 return false1280 return false
@@ -1617,7 +1568,6 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1617 d.clearBarScriptsForSession(sess.id)1568 d.clearBarScriptsForSession(sess.id)
1618 d.purgeParkingForSessionLocked(sess)1569 d.purgeParkingForSessionLocked(sess)
1619 parkedRetirements := d.purgeParkedForSessionLocked(sess)1570 parkedRetirements := d.purgeParkedForSessionLocked(sess)
1620- detachedActive := sess.client
1621 attachments := make([]detachedAttachmentSnapshot, 0, len(participants.roleGates))1571 attachments := make([]detachedAttachmentSnapshot, 0, len(participants.roleGates))
1622 capture := func(attached *attachedClient) {1572 capture := func(attached *attachedClient) {
1623 if attached == nil {1573 if attached == nil {
@@ -1631,13 +1581,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1631 for attached := range sess.attachments {1581 for attached := range sess.attachments {
1632 capture(attached)1582 capture(attached)
1633 }1583 }
1634- for snatched := range sess.snatched {
1635- capture(snatched)
1636- }
1637- capture(sess.client)
1638- sess.client = nil
1639 clear(sess.attachments)1584 clear(sess.attachments)
1640- clear(sess.snatched)
1641 stoppedName := sess.name1585 stoppedName := sess.name
1642 stoppedCwd := sess.cwd1586 stoppedCwd := sess.cwd
1643 createdAt := sess.createdAt1587 createdAt := sess.createdAt
@@ -1688,7 +1632,9 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1688 sess.mu.Unlock()1632 sess.mu.Unlock()
1689 for _, tb := range tabs {1633 for _, tb := range tabs {
1690 d.clearDestroyedTabPreview(tb)1634 d.clearDestroyedTabPreview(tb)
1691- d.teardownFloating(tb, detachedActive)1635+ for _, attachment := range attachments {
1636+ d.teardownFloating(tb, attachment.ac)
1637+ }
1692 }1638 }
1693 for _, path := range clipFiles {1639 for _, path := range clipFiles {
1694 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {1640 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
@@ -1801,7 +1747,7 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
1801 if d.procCwd == nil {1747 if d.procCwd == nil {
1802 return1748 return
1803 }1749 }
1804- tb := sess.activeTab()1750+ tb := sess.tabForAttachmentOrActive(nil)
1805 if tb == nil {1751 if tb == nil {
1806 return1752 return
1807 }1753 }
M internal/usecase/daemon/session_core.go +3 -6
@@ -55,16 +55,13 @@ func (s *session) capturePrimary(ac *attachedClient, req primaryCaptureRequest)
5555 return captureLocalPrimaryRenderState(s, ac, req)
5656 }
5757
58-// activateTargetLocked validates and selects a local tab. Caller holds s.mu.
58+// activateTargetLocked validates a legacy session target. Client-facing
59+// navigation updates attachment views through selectAttachmentTab.
5960 func (s *session) activateTargetLocked(tabIndex int) bool {
6061 if tabIndex < 0 {
6162 return true
6263 }
63- if tabIndex >= len(s.tabs) {
64- return false
65- }
66- s.active = tabIndex
67- return true
64+ return tabIndex < len(s.tabs)
6865 }
6966
7067 // localSession narrows an attachment entry for operations that own local PTYs,
@@ -55,16 +55,13 @@ func (s *session) capturePrimary(ac *attachedClient, req primaryCaptureRequest)
55 return captureLocalPrimaryRenderState(s, ac, req)55 return captureLocalPrimaryRenderState(s, ac, req)
56 }56 }
57 57
58-// activateTargetLocked validates and selects a local tab. Caller holds s.mu.58+// activateTargetLocked validates a legacy session target. Client-facing
59+// navigation updates attachment views through selectAttachmentTab.
59 func (s *session) activateTargetLocked(tabIndex int) bool {60 func (s *session) activateTargetLocked(tabIndex int) bool {
60 if tabIndex < 0 {61 if tabIndex < 0 {
61 return true62 return true
62 }63 }
63- if tabIndex >= len(s.tabs) {64+ return tabIndex < len(s.tabs)
64- return false
65- }
66- s.active = tabIndex
67- return true
68 }65 }
69 66
70 // localSession narrows an attachment entry for operations that own local PTYs,67 // localSession narrows an attachment entry for operations that own local PTYs,
M internal/usecase/daemon/session_meta.go +18 -3
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "errors"
55
6+ "github.com/bnema/vev/internal/domain"
67 "github.com/bnema/vev/internal/ports"
78 )
89
@@ -11,17 +12,31 @@ var errSessionMetaUnavailable = errors.New("session metadata is unavailable")
1112 // sessionMetaSnapshot captures the authoritative tab order while session.mu
1213 // owns every field represented on the wire.
1314 func (s *session) sessionMetaSnapshot() (ports.SessionMeta, bool) {
15+ return s.sessionMetaSnapshotFor(nil)
16+}
17+
18+func (s *session) sessionMetaSnapshotFor(ac *attachedClient) (ports.SessionMeta, bool) {
1419 if s == nil {
1520 return ports.SessionMeta{}, false
1621 }
1722 s.mu.Lock()
1823 defer s.mu.Unlock()
19- if len(s.tabs) == 0 || s.active < 0 || s.active >= len(s.tabs) {
24+ if len(s.tabs) == 0 {
2025 return ports.SessionMeta{}, false
2126 }
27+ active := 0
28+ if ac != nil {
29+ view := ac.viewSnapshot()
30+ for i, tb := range s.tabs {
31+ if domain.TabStableID(tb.stableID) == view.tabID {
32+ active = i
33+ break
34+ }
35+ }
36+ }
2237 meta := ports.SessionMeta{
2338 SessionName: s.name,
24- Active: uint16(s.active),
39+ Active: uint16(active),
2540 Tabs: make([]ports.SessionTabMeta, len(s.tabs)),
2641 }
2742 for i, tb := range s.tabs {
@@ -67,7 +82,7 @@ func (ac *attachedClient) sendSessionMetaIfChanged(sess *session, expected trans
6782 if ac == nil || sess == nil || !ac.proxied {
6883 return nil
6984 }
70- meta, ok := sess.sessionMetaSnapshot()
85+ meta, ok := sess.sessionMetaSnapshotFor(ac)
7186 if !ok {
7287 return errSessionMetaUnavailable
7388 }
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "errors"4 "errors"
5 5
6+ "github.com/bnema/vev/internal/domain"
6 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
7 )8 )
8 9
@@ -11,17 +12,31 @@ var errSessionMetaUnavailable = errors.New("session metadata is unavailable")
11 // sessionMetaSnapshot captures the authoritative tab order while session.mu12 // sessionMetaSnapshot captures the authoritative tab order while session.mu
12 // owns every field represented on the wire.13 // owns every field represented on the wire.
13 func (s *session) sessionMetaSnapshot() (ports.SessionMeta, bool) {14 func (s *session) sessionMetaSnapshot() (ports.SessionMeta, bool) {
15+ return s.sessionMetaSnapshotFor(nil)
16+}
17+
18+func (s *session) sessionMetaSnapshotFor(ac *attachedClient) (ports.SessionMeta, bool) {
14 if s == nil {19 if s == nil {
15 return ports.SessionMeta{}, false20 return ports.SessionMeta{}, false
16 }21 }
17 s.mu.Lock()22 s.mu.Lock()
18 defer s.mu.Unlock()23 defer s.mu.Unlock()
19- if len(s.tabs) == 0 || s.active < 0 || s.active >= len(s.tabs) {24+ if len(s.tabs) == 0 {
20 return ports.SessionMeta{}, false25 return ports.SessionMeta{}, false
21 }26 }
27+ active := 0
28+ if ac != nil {
29+ view := ac.viewSnapshot()
30+ for i, tb := range s.tabs {
31+ if domain.TabStableID(tb.stableID) == view.tabID {
32+ active = i
33+ break
34+ }
35+ }
36+ }
22 meta := ports.SessionMeta{37 meta := ports.SessionMeta{
23 SessionName: s.name,38 SessionName: s.name,
24- Active: uint16(s.active),39+ Active: uint16(active),
25 Tabs: make([]ports.SessionTabMeta, len(s.tabs)),40 Tabs: make([]ports.SessionTabMeta, len(s.tabs)),
26 }41 }
27 for i, tb := range s.tabs {42 for i, tb := range s.tabs {
@@ -67,7 +82,7 @@ func (ac *attachedClient) sendSessionMetaIfChanged(sess *session, expected trans
67 if ac == nil || sess == nil || !ac.proxied {82 if ac == nil || sess == nil || !ac.proxied {
68 return nil83 return nil
69 }84 }
70- meta, ok := sess.sessionMetaSnapshot()85+ meta, ok := sess.sessionMetaSnapshotFor(ac)
71 if !ok {86 if !ok {
72 return errSessionMetaUnavailable87 return errSessionMetaUnavailable
73 }88 }
M internal/usecase/daemon/session_view.go +13 -2
@@ -53,15 +53,26 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
5353 opts.tabDetails = true
5454 }
5555 s.mu.Lock()
56+ active := 0
57+ if len(s.attachments) != 0 {
58+ for i, tb := range s.tabs {
59+ for ac := range s.attachments {
60+ if ac.viewSnapshot().tabID == domain.TabStableID(tb.stableID) {
61+ active = i
62+ break
63+ }
64+ }
65+ }
66+ }
5667 view := sessionView{
5768 id: s.id,
5869 incarnation: s.incarnation,
5970 name: s.name,
6071 ephemeral: s.ephemeral,
6172 createdAt: s.createdAt,
62- active: s.active,
73+ active: active,
6374 mruAt: s.mruAt.Load(),
64- attached: len(s.attachments) != 0 || s.client != nil,
75+ attached: len(s.attachments) != 0,
6576 tabCount: len(s.tabs),
6677 cannotAcceptMoves: s.capabilities().cannotAcceptMoves,
6778 }
@@ -53,15 +53,26 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
53 opts.tabDetails = true53 opts.tabDetails = true
54 }54 }
55 s.mu.Lock()55 s.mu.Lock()
56+ active := 0
57+ if len(s.attachments) != 0 {
58+ for i, tb := range s.tabs {
59+ for ac := range s.attachments {
60+ if ac.viewSnapshot().tabID == domain.TabStableID(tb.stableID) {
61+ active = i
62+ break
63+ }
64+ }
65+ }
66+ }
56 view := sessionView{67 view := sessionView{
57 id: s.id,68 id: s.id,
58 incarnation: s.incarnation,69 incarnation: s.incarnation,
59 name: s.name,70 name: s.name,
60 ephemeral: s.ephemeral,71 ephemeral: s.ephemeral,
61 createdAt: s.createdAt,72 createdAt: s.createdAt,
62- active: s.active,73+ active: active,
63 mruAt: s.mruAt.Load(),74 mruAt: s.mruAt.Load(),
64- attached: len(s.attachments) != 0 || s.client != nil,75+ attached: len(s.attachments) != 0,
65 tabCount: len(s.tabs),76 tabCount: len(s.tabs),
66 cannotAcceptMoves: s.capabilities().cannotAcceptMoves,77 cannotAcceptMoves: s.capabilities().cannotAcceptMoves,
67 }78 }
M internal/usecase/daemon/snapshot_capture.go +1 -1
@@ -13,7 +13,7 @@ func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapsh
1313 name: sess.name,
1414 incarnation: sess.incarnation,
1515 createdAt: uint64(sess.createdAt),
16- active: uint16(max(sess.active, 0)),
16+ active: 0,
1717 }
1818 ephemeral := sess.ephemeral
1919 fallbackCwd := sess.cwd
@@ -13,7 +13,7 @@ func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapsh
13 name: sess.name,13 name: sess.name,
14 incarnation: sess.incarnation,14 incarnation: sess.incarnation,
15 createdAt: uint64(sess.createdAt),15 createdAt: uint64(sess.createdAt),
16- active: uint16(max(sess.active, 0)),16+ active: 0,
17 }17 }
18 ephemeral := sess.ephemeral18 ephemeral := sess.ephemeral
19 fallbackCwd := sess.cwd19 fallbackCwd := sess.cwd
M internal/usecase/daemon/snapshot_restore.go +1 -1
@@ -290,7 +290,7 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
290290 }
291291
292292 func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab, term terminalEnv) *session {
293- sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, active: int(snap.Active), terminal: term, env: copyEnvironment(d.baseEnv), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}
293+ sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, terminal: term, env: copyEnvironment(d.baseEnv), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}
294294 // Restored tabs remain private until persistAndRegisterRestoredSession.
295295 // Initialize owners now so registration and reader startup cannot expose an
296296 // ownerless pane.
@@ -290,7 +290,7 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
290 }290 }
291 291
292 func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab, term terminalEnv) *session {292 func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab, term terminalEnv) *session {
293- sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, active: int(snap.Active), terminal: term, env: copyEnvironment(d.baseEnv), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}293+ sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, terminal: term, env: copyEnvironment(d.baseEnv), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}
294 // Restored tabs remain private until persistAndRegisterRestoredSession.294 // Restored tabs remain private until persistAndRegisterRestoredSession.
295 // Initialize owners now so registration and reader startup cannot expose an295 // Initialize owners now so registration and reader startup cannot expose an
296 // ownerless pane.296 // ownerless pane.
M internal/usecase/daemon/status.go +30 -3
@@ -6,6 +6,7 @@ import (
66 "strconv"
77 "time"
88
9+ "github.com/bnema/vev/internal/domain"
910 "github.com/bnema/vev/internal/usecase/command"
1011 "github.com/bnema/vev/internal/usecase/palette"
1112 themeui "github.com/bnema/vev/internal/usecase/theme"
@@ -181,6 +182,10 @@ type statusTab struct {
181182 }
182183
183184 func (s *session) statusSegments(includeTerminalTitle bool) statusSnapshot {
185+ return s.statusSegmentsFor(nil, includeTerminalTitle)
186+}
187+
188+func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle bool) statusSnapshot {
184189 s.mu.Lock()
185190 defer s.mu.Unlock()
186191 name := s.name
@@ -188,9 +193,19 @@ func (s *session) statusSegments(includeTerminalTitle bool) statusSnapshot {
188193 name += "*"
189194 }
190195 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
196+ activeIndex := 0
197+ if ac != nil {
198+ view := ac.viewSnapshot()
199+ for i, tb := range s.tabs {
200+ if domain.TabStableID(tb.stableID) == view.tabID {
201+ activeIndex = i
202+ break
203+ }
204+ }
205+ }
191206 for i, tb := range s.tabs {
192207 name := tabDisplayName(tb, i)
193- active := i == s.active
208+ active := i == activeIndex
194209 attention := tb.attention && (!active || tb.attentionVisiblePaint)
195210 snap.tabs[i] = statusTab{name: name, paneTitle: tb.focusedPaneTitle(includeTerminalTitle), active: active, attention: attention}
196211 }
@@ -236,6 +251,10 @@ func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hi
236251 // attachment implementation. Bar scripts remain local-session-only because
237252 // their existing execution contract depends on local tabs and PTYs.
238253 func (d *Daemon) barStateForAttachmentPaletteHints(cur attachmentSession, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
254+ return d.barStateForAttachmentPaletteHintsFor(cur, nil, statusFeedback, hints, recent)
255+}
256+
257+func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur attachmentSession, ac *attachedClient, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
239258 if attachmentSessionCore(cur) == nil {
240259 cur = nil
241260 }
@@ -249,7 +268,11 @@ func (d *Daemon) barStateForAttachmentPaletteHints(cur attachmentSession, status
249268 if d != nil {
250269 includeTerminalTitle = d.currentTabsConfig().TerminalTitle
251270 }
252- state.status = cur.statusSegments(includeTerminalTitle)
271+ if local, ok := localSession(cur); ok {
272+ state.status = local.statusSegmentsFor(ac, includeTerminalTitle)
273+ } else {
274+ state.status = cur.statusSegments(includeTerminalTitle)
275+ }
253276 }
254277 if local, ok := localSession(cur); ok && d != nil {
255278 state.topRight, state.bottomRight = d.barScriptSnapshot(local)
@@ -269,6 +292,10 @@ func (d *Daemon) barStateFor(cur *session, statusFeedback string) barState {
269292 }
270293
271294 func (d *Daemon) barStateBase(cur *session, statusFeedback string) barState {
295+ return d.barStateBaseFor(cur, nil, statusFeedback)
296+}
297+
298+func (d *Daemon) barStateBaseFor(cur *session, ac *attachedClient, statusFeedback string) barState {
272299 state := barState{statusFeedback: statusFeedback}
273300 if d != nil {
274301 state.attentionFrame = d.attentionFrame()
@@ -278,7 +305,7 @@ func (d *Daemon) barStateBase(cur *session, statusFeedback string) barState {
278305 if d != nil {
279306 includeTerminalTitle = d.currentTabsConfig().TerminalTitle
280307 }
281- state.status = cur.statusSegments(includeTerminalTitle)
308+ state.status = cur.statusSegmentsFor(ac, includeTerminalTitle)
282309 }
283310 if d != nil {
284311 state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
@@ -6,6 +6,7 @@ import (
6 "strconv"6 "strconv"
7 "time"7 "time"
8 8
9+ "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/usecase/command"10 "github.com/bnema/vev/internal/usecase/command"
10 "github.com/bnema/vev/internal/usecase/palette"11 "github.com/bnema/vev/internal/usecase/palette"
11 themeui "github.com/bnema/vev/internal/usecase/theme"12 themeui "github.com/bnema/vev/internal/usecase/theme"
@@ -181,6 +182,10 @@ type statusTab struct {
181 }182 }
182 183
183 func (s *session) statusSegments(includeTerminalTitle bool) statusSnapshot {184 func (s *session) statusSegments(includeTerminalTitle bool) statusSnapshot {
185+ return s.statusSegmentsFor(nil, includeTerminalTitle)
186+}
187+
188+func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle bool) statusSnapshot {
184 s.mu.Lock()189 s.mu.Lock()
185 defer s.mu.Unlock()190 defer s.mu.Unlock()
186 name := s.name191 name := s.name
@@ -188,9 +193,19 @@ func (s *session) statusSegments(includeTerminalTitle bool) statusSnapshot {
188 name += "*"193 name += "*"
189 }194 }
190 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}195 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
196+ activeIndex := 0
197+ if ac != nil {
198+ view := ac.viewSnapshot()
199+ for i, tb := range s.tabs {
200+ if domain.TabStableID(tb.stableID) == view.tabID {
201+ activeIndex = i
202+ break
203+ }
204+ }
205+ }
191 for i, tb := range s.tabs {206 for i, tb := range s.tabs {
192 name := tabDisplayName(tb, i)207 name := tabDisplayName(tb, i)
193- active := i == s.active208+ active := i == activeIndex
194 attention := tb.attention && (!active || tb.attentionVisiblePaint)209 attention := tb.attention && (!active || tb.attentionVisiblePaint)
195 snap.tabs[i] = statusTab{name: name, paneTitle: tb.focusedPaneTitle(includeTerminalTitle), active: active, attention: attention}210 snap.tabs[i] = statusTab{name: name, paneTitle: tb.focusedPaneTitle(includeTerminalTitle), active: active, attention: attention}
196 }211 }
@@ -236,6 +251,10 @@ func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hi
236 // attachment implementation. Bar scripts remain local-session-only because251 // attachment implementation. Bar scripts remain local-session-only because
237 // their existing execution contract depends on local tabs and PTYs.252 // their existing execution contract depends on local tabs and PTYs.
238 func (d *Daemon) barStateForAttachmentPaletteHints(cur attachmentSession, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {253 func (d *Daemon) barStateForAttachmentPaletteHints(cur attachmentSession, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
254+ return d.barStateForAttachmentPaletteHintsFor(cur, nil, statusFeedback, hints, recent)
255+}
256+
257+func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur attachmentSession, ac *attachedClient, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
239 if attachmentSessionCore(cur) == nil {258 if attachmentSessionCore(cur) == nil {
240 cur = nil259 cur = nil
241 }260 }
@@ -249,7 +268,11 @@ func (d *Daemon) barStateForAttachmentPaletteHints(cur attachmentSession, status
249 if d != nil {268 if d != nil {
250 includeTerminalTitle = d.currentTabsConfig().TerminalTitle269 includeTerminalTitle = d.currentTabsConfig().TerminalTitle
251 }270 }
252- state.status = cur.statusSegments(includeTerminalTitle)271+ if local, ok := localSession(cur); ok {
272+ state.status = local.statusSegmentsFor(ac, includeTerminalTitle)
273+ } else {
274+ state.status = cur.statusSegments(includeTerminalTitle)
275+ }
253 }276 }
254 if local, ok := localSession(cur); ok && d != nil {277 if local, ok := localSession(cur); ok && d != nil {
255 state.topRight, state.bottomRight = d.barScriptSnapshot(local)278 state.topRight, state.bottomRight = d.barScriptSnapshot(local)
@@ -269,6 +292,10 @@ func (d *Daemon) barStateFor(cur *session, statusFeedback string) barState {
269 }292 }
270 293
271 func (d *Daemon) barStateBase(cur *session, statusFeedback string) barState {294 func (d *Daemon) barStateBase(cur *session, statusFeedback string) barState {
295+ return d.barStateBaseFor(cur, nil, statusFeedback)
296+}
297+
298+func (d *Daemon) barStateBaseFor(cur *session, ac *attachedClient, statusFeedback string) barState {
272 state := barState{statusFeedback: statusFeedback}299 state := barState{statusFeedback: statusFeedback}
273 if d != nil {300 if d != nil {
274 state.attentionFrame = d.attentionFrame()301 state.attentionFrame = d.attentionFrame()
@@ -278,7 +305,7 @@ func (d *Daemon) barStateBase(cur *session, statusFeedback string) barState {
278 if d != nil {305 if d != nil {
279 includeTerminalTitle = d.currentTabsConfig().TerminalTitle306 includeTerminalTitle = d.currentTabsConfig().TerminalTitle
280 }307 }
281- state.status = cur.statusSegments(includeTerminalTitle)308+ state.status = cur.statusSegmentsFor(ac, includeTerminalTitle)
282 }309 }
283 if d != nil {310 if d != nil {
284 state.topRight, state.bottomRight = d.barScriptSnapshot(cur)311 state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
M internal/usecase/daemon/theme_resolve.go +1 -12
@@ -12,14 +12,6 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme
1212 return d.applyHostThemeLocked(sess, ac, t, clearUnknownScheme)
1313 }
1414
15-// applyHostThemeSendLocked applies a theme when the caller already owns
16-// ac.sendMu, preserving the usual sendMu -> themeMu lock order.
17-func (d *Daemon) applyHostThemeSendLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
18- sess.themeMu.Lock()
19- defer sess.themeMu.Unlock()
20- return d.applyHostThemeLocked(sess, ac, t, clearUnknownScheme)
21-}
22-
2315 func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
2416 // Resolve while applying, never while composing. The theme mutex only
2517 // publishes the completed value and is not held across session/tab/pane
@@ -28,13 +20,10 @@ func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme
2820 t = applied.Raw
2921 sess.mu.Lock()
3022 if ac != nil {
31- if sess.client != ac {
23+ if _, attached := sess.attachments[ac]; !attached {
3224 sess.mu.Unlock()
3325 return false
3426 }
35- } else if sess.client != nil {
36- sess.mu.Unlock()
37- return false
3827 }
3928 tabs := append([]*tab(nil), sess.tabs...)
4029 sess.mu.Unlock()
@@ -12,14 +12,6 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme
12 return d.applyHostThemeLocked(sess, ac, t, clearUnknownScheme)12 return d.applyHostThemeLocked(sess, ac, t, clearUnknownScheme)
13 }13 }
14 14
15-// applyHostThemeSendLocked applies a theme when the caller already owns
16-// ac.sendMu, preserving the usual sendMu -> themeMu lock order.
17-func (d *Daemon) applyHostThemeSendLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
18- sess.themeMu.Lock()
19- defer sess.themeMu.Unlock()
20- return d.applyHostThemeLocked(sess, ac, t, clearUnknownScheme)
21-}
22-
23 func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {15 func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
24 // Resolve while applying, never while composing. The theme mutex only16 // Resolve while applying, never while composing. The theme mutex only
25 // publishes the completed value and is not held across session/tab/pane17 // publishes the completed value and is not held across session/tab/pane
@@ -28,13 +20,10 @@ func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme
28 t = applied.Raw20 t = applied.Raw
29 sess.mu.Lock()21 sess.mu.Lock()
30 if ac != nil {22 if ac != nil {
31- if sess.client != ac {23+ if _, attached := sess.attachments[ac]; !attached {
32 sess.mu.Unlock()24 sess.mu.Unlock()
33 return false25 return false
34 }26 }
35- } else if sess.client != nil {
36- sess.mu.Unlock()
37- return false
38 }27 }
39 tabs := append([]*tab(nil), sess.tabs...)28 tabs := append([]*tab(nil), sess.tabs...)
40 sess.mu.Unlock()29 sess.mu.Unlock()
M internal/usecase/daemon/transactional_resize.go +3 -7
@@ -362,10 +362,7 @@ func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) {
362362 return
363363 }
364364 markSnapshotDirty(sess)
365- sess.mu.Lock()
366- ac := sess.client
367- sess.mu.Unlock()
368- if ac != nil {
365+ for _, ac := range sess.snapshotAttachments() {
369366 d.invalidateRender(sess, ac, true, "transactional_resize.go")
370367 }
371368 }
@@ -558,7 +555,6 @@ func (d *Daemon) applySessionLayoutWithMode(sess *session, size domain.Size, pro
558555 }
559556 sess.mu.Lock()
560557 tabs := append([]*tab(nil), sess.tabs...)
561- active := sess.active
562558 sess.mu.Unlock()
563559 plans := make([]*preparedTabLayout, 0, len(tabs))
564560 for _, tb := range tabs {
@@ -633,8 +629,8 @@ func (d *Daemon) applySessionLayoutWithMode(sess *session, size domain.Size, pro
633629 }
634630 }
635631 }
636- if active >= 0 && active < len(tabs) {
637- floatingFailed, ok := d.applyVisibleFloatingLayout(sess, tabs[active], current)
632+ for _, tb := range tabs {
633+ floatingFailed, ok := d.applyVisibleFloatingLayout(sess, tb, current)
638634 if !ok {
639635 return nil, false
640636 }
@@ -362,10 +362,7 @@ func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) {
362 return362 return
363 }363 }
364 markSnapshotDirty(sess)364 markSnapshotDirty(sess)
365- sess.mu.Lock()365+ for _, ac := range sess.snapshotAttachments() {
366- ac := sess.client
367- sess.mu.Unlock()
368- if ac != nil {
369 d.invalidateRender(sess, ac, true, "transactional_resize.go")366 d.invalidateRender(sess, ac, true, "transactional_resize.go")
370 }367 }
371 }368 }
@@ -558,7 +555,6 @@ func (d *Daemon) applySessionLayoutWithMode(sess *session, size domain.Size, pro
558 }555 }
559 sess.mu.Lock()556 sess.mu.Lock()
560 tabs := append([]*tab(nil), sess.tabs...)557 tabs := append([]*tab(nil), sess.tabs...)
561- active := sess.active
562 sess.mu.Unlock()558 sess.mu.Unlock()
563 plans := make([]*preparedTabLayout, 0, len(tabs))559 plans := make([]*preparedTabLayout, 0, len(tabs))
564 for _, tb := range tabs {560 for _, tb := range tabs {
@@ -633,8 +629,8 @@ func (d *Daemon) applySessionLayoutWithMode(sess *session, size domain.Size, pro
633 }629 }
634 }630 }
635 }631 }
636- if active >= 0 && active < len(tabs) {632+ for _, tb := range tabs {
637- floatingFailed, ok := d.applyVisibleFloatingLayout(sess, tabs[active], current)633+ floatingFailed, ok := d.applyVisibleFloatingLayout(sess, tb, current)
638 if !ok {634 if !ok {
639 return nil, false635 return nil, false
640 }636 }

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