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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 128 | 128 | } | |
| 129 | 129 | delete(c.attachments, ac) | |
| 130 | 130 | delete(c.attachmentOrder, ac) | |
| 131 | - | delete(c.snatched, ac) | |
| 132 | - | if c.client == ac { | |
| 133 | - | c.client = nil | |
| 134 | - | } | |
| 135 | 131 | return true | |
| 136 | 132 | } | |
| 137 | 133 | ||
| @@ -155,6 +151,17 @@ func (s *session) unregisterAttachment(ac *attachedClient) bool { | |||
| 155 | 151 | ||
| 156 | 152 | // snapshotAttachmentsLocked returns a stable registration-ordered snapshot. | |
| 157 | 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 | 165 | func (s *session) snapshotAttachmentsLocked() []*attachedClient { | |
| 159 | 166 | if s == nil || len(s.attachments) == 0 { | |
| 160 | 167 | return nil | |
| @@ -174,6 +181,46 @@ func (s *session) snapshotAttachmentsLocked() []*attachedClient { | |||
| 174 | 181 | 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 | 224 | func (s *session) snapshotAttachments() []*attachedClient { | |
| 178 | 225 | if s == nil { | |
| 179 | 226 | return nil | |
| @@ -335,11 +382,43 @@ func (s *session) tabForAttachment(ac *attachedClient) *tab { | |||
| 335 | 382 | ||
| 336 | 383 | // paneForAttachment resolves both stable IDs and repairs a removed pane before | |
| 337 | 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 | 407 | func (s *session) tabForAttachmentOrActive(ac *attachedClient) *tab { | |
| 339 | 408 | if tb := s.tabForAttachment(ac); tb != nil { | |
| 340 | 409 | 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 | 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 | 466 | // selectAttachmentTab changes only one attachment's target. Shared tab order | |
| 388 | 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 | 481 | func (s *session) selectAttachmentTab(ac *attachedClient, tabID domain.TabStableID) bool { | |
| 390 | 482 | if s == nil || ac == nil { | |
| 391 | 483 | return false | |
| @@ -410,6 +502,14 @@ func (s *session) selectAttachmentTab(ac *attachedClient, tabID domain.TabStable | |||
| 410 | 502 | 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 | 513 | func (s *session) switchAttachmentTab(ac *attachedClient, idx int) bool { | |
| 414 | 514 | if s == nil || ac == nil { | |
| 415 | 515 | return false | |
| @@ -428,6 +528,22 @@ func (s *session) switchAttachmentTab(ac *attachedClient, idx int) bool { | |||
| 428 | 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 | 547 | // repairAttachmentViewsLocked repairs all live attachment targets after a tab | |
| 432 | 548 | // or pane mutation. It is intended to run at the same mutation linearization | |
| 433 | 549 | // 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 true | 131 | 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 nil | 167 | return nil | ||
| @@ -174,6 +181,46 @@ func (s *session) snapshotAttachmentsLocked() []*attachedClient { | |||||
| 174 | return out | 181 | 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 nil | 226 | 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 before | 383 | // 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 tb | 409 | 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 order | 466 | // 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 false | 483 | return false | ||
| @@ -410,6 +502,14 @@ func (s *session) selectAttachmentTab(ac *attachedClient, tabID domain.TabStable | |||||
| 410 | return changed | 502 | 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 false | 515 | 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 tab | 547 | // repairAttachmentViewsLocked repairs all live attachment targets after a tab | ||
| 432 | // or pane mutation. It is intended to run at the same mutation linearization | 548 | // 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 | |||
| 39 | 39 | return attachmentDetached | |
| 40 | 40 | } | |
| 41 | 41 | 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 { | |
| 46 | 44 | return attachmentActive | |
| 47 | 45 | } | |
| 48 | - | if _, ok := core.snatched[ac]; ok { | |
| 49 | - | return attachmentSnatched | |
| 50 | - | } | |
| 51 | 46 | return attachmentDetached | |
| 52 | 47 | } | |
| 53 | 48 | ||
| 54 | 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 | 52 | if entry == nil || ac == nil || entry.core() == nil { | |
| 56 | 53 | return | |
| 57 | 54 | } | |
| 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) | |
| 63 | 56 | } | |
| 64 | 57 | ||
| 65 | 58 | func (s *session) attachmentRole(ac *attachedClient) attachmentRole { | |
| @@ -39,27 +39,20 @@ func attachmentSessionRoleLocked(entry attachmentSession, ac *attachedClient) at | |||||
| 39 | return attachmentDetached | 39 | return attachmentDetached | ||
| 40 | } | 40 | } | ||
| 41 | core := entry.core() | 41 | core := entry.core() | ||
| 42 | - | // Membership is the lifecycle authority. Keep the primary pointer as a | 42 | + | // Membership is the lifecycle authority for every independent attachment. |
| 43 | - | // narrow construction-time fallback for older headless fixtures; attached | 43 | + | 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 attachmentActive | 44 | return attachmentActive | ||
| 47 | } | 45 | } | ||
| 48 | - | if _, ok := core.snatched[ac]; ok { | |||
| 49 | - | return attachmentSnatched | |||
| 50 | - | } | |||
| 51 | return attachmentDetached | 46 | 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 | return | 53 | 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) ( | |||
| 109 | 109 | d.sessions[sourceCore.id] != source || req.target != nil && d.sessions[targetCore.id] != req.target { | |
| 110 | 110 | return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition | |
| 111 | 111 | } | |
| 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 | |
| 120 | 116 | if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) { | |
| 121 | 117 | return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition | |
| 122 | 118 | } | |
| 123 | 119 | req.preflighted = true | |
| 124 | 120 | 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. | |
| 134 | 122 | return req, participants, nil | |
| 135 | 123 | } | |
| 136 | 124 | ||
| @@ -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{}, errAttachmentTransition | 110 | 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.client | 114 | + | // 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{}, errAttachmentTransition | 117 | return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition | ||
| 122 | } | 118 | } | ||
| 123 | req.preflighted = true | 119 | 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 an | 121 | + | // 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, nil | 122 | return req, participants, nil | ||
| 135 | } | 123 | } | ||
| 136 | 124 | ||||
M internal/usecase/daemon/attachment_transition_publish.go +32 -52
| @@ -1,6 +1,10 @@ | |||
| 1 | 1 | 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 | 9 | // transitionSourcePreflightLocked validates an initiating role while its gate | |
| 6 | 10 | // 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 | 64 | return true | |
| 61 | 65 | } | |
| 62 | 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 | 68 | return false | |
| 65 | 69 | } | |
| 66 | 70 | expected.mu.Lock() | |
| @@ -141,14 +145,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR | |||
| 141 | 145 | ||
| 142 | 146 | var targetCoordinator *renderCoordinator | |
| 143 | 147 | 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) | |
| 152 | 149 | } | |
| 153 | 150 | sourceCoordinator := sourceCore.coordinator.Load() | |
| 154 | 151 | if attachmentSessionRoleLocked(source, req.next) != req.expectedRole || | |
| @@ -156,8 +153,8 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR | |||
| 156 | 153 | return nil, errAttachmentTransition | |
| 157 | 154 | } | |
| 158 | 155 | ||
| 159 | - | old := targetCore.client | |
| 160 | - | if old != req.expectedTargetCurrent || source != req.target && old == req.next { | |
| 156 | + | old := (*attachedClient)(nil) | |
| 157 | + | if req.expectedTargetCurrent != nil { | |
| 161 | 158 | return nil, errAttachmentTransition | |
| 162 | 159 | } | |
| 163 | 160 | publication := &attachmentPublication{ | |
| @@ -167,7 +164,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR | |||
| 167 | 164 | } | |
| 168 | 165 | invalid := req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) || | |
| 169 | 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 | 168 | invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil && | |
| 172 | 169 | (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next) | |
| 173 | 170 | invalid = invalid || !attachmentLifecycleCurrentLocked(req.target, req.expectedTargetLifecycle) | |
| @@ -183,6 +180,11 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR | |||
| 183 | 180 | // part of the same locked publication. It reports role-preserving transitions. | |
| 184 | 181 | func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool { | |
| 185 | 182 | 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 | 188 | if req.copySourceEnvironment { | |
| 187 | 189 | source, sourceOK := localSession(publication.source) | |
| 188 | 190 | target, targetOK := localSession(req.target) | |
| @@ -198,28 +200,11 @@ func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool | |||
| 198 | 200 | // generations, and currentSession while all role gates remain frozen. | |
| 199 | 201 | func publishAttachmentOwnershipLocked(publication *attachmentPublication) { | |
| 200 | 202 | 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. | |
| 205 | 205 | 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) | |
| 223 | 208 | } | |
| 224 | 209 | publication.nextGeneration = req.next.roleGeneration.Add(1) | |
| 225 | 210 | req.next.setSession(req.target) | |
| @@ -234,10 +219,16 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att | |||
| 234 | 219 | result.cleanups = append(result.cleanups, publication.sourceCoordinator.beginDetachLocked(req.next)) | |
| 235 | 220 | } | |
| 236 | 221 | var lease *attachmentLease | |
| 237 | - | if publication.targetCoordinator != nil && publication.old == nil { | |
| 222 | + | if publication.targetCoordinator != nil { | |
| 238 | 223 | 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 | + | } | |
| 241 | 232 | } | |
| 242 | 233 | result.published = attachmentRoleToken{ | |
| 243 | 234 | sess: req.target, | |
| @@ -248,20 +239,7 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att | |||
| 248 | 239 | lease: lease, | |
| 249 | 240 | rebase: publication.source != req.target && req.expectedRole == attachmentActive, | |
| 250 | 241 | } | |
| 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 | + | ||
| 265 | 243 | // Membership, currentSession, lease, generation, and both exact admission | |
| 266 | 244 | // capabilities become visible as one frozen publication. The gates remain | |
| 267 | 245 | // frozen until every architecture lock has been released. | |
| @@ -278,10 +256,12 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att | |||
| 278 | 256 | // ownership, generation, and coordinator-lease publication. The caller retains | |
| 279 | 257 | // every lock required by validateAttachmentTransitionPrelocked. | |
| 280 | 258 | func (d *Daemon) publishAttachmentTransitionPrelocked(publication *attachmentPublication) attachmentTransitionResult { | |
| 281 | - | if d.applyTargetStateLocked(publication) { | |
| 259 | + | if publication.req.preserveRole { | |
| 260 | + | d.applyTargetStateLocked(publication) | |
| 282 | 261 | return attachmentTransitionResult{published: *publication.req.sourceToken} | |
| 283 | 262 | } | |
| 284 | 263 | publishAttachmentOwnershipLocked(publication) | |
| 264 | + | d.applyTargetStateLocked(publication) | |
| 285 | 265 | return buildAttachmentPostcommitPlanLocked(publication) | |
| 286 | 266 | } | |
| 287 | 267 | ||
| @@ -1,6 +1,10 @@ | |||||
| 1 | package daemon | 1 | 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 gate | 9 | // transitionSourcePreflightLocked validates an initiating role while its gate | ||
| 6 | // is frozen and d.mu is held. No caller may create or delete a target before | 10 | // 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 true | 64 | 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 false | 68 | 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 *renderCoordinator | 146 | var targetCoordinator *renderCoordinator | ||
| 143 | if req.targetRole == attachmentActive { | 147 | if req.targetRole == attachmentActive { | ||
| 144 | - | // The coordinator remains session-owned while attachments join the | 148 | + | 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, errAttachmentTransition | 153 | return nil, errAttachmentTransition | ||
| 157 | } | 154 | } | ||
| 158 | 155 | ||||
| 159 | - | old := targetCore.client | 156 | + | old := (*attachedClient)(nil) |
| 160 | - | if old != req.expectedTargetCurrent || source != req.target && old == req.next { | 157 | + | if req.expectedTargetCurrent != nil { |
| 161 | return nil, errAttachmentTransition | 158 | 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.req | 182 | 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.req | 202 | req := publication.req | ||
| 201 | - | // Membership is collection-owned and is published before role metadata. A | 203 | + | // Session membership is the sole routing publication. Existing attachments |
| 202 | - | // second attachment therefore coexists with the first even when the legacy | 204 | + | // 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 and | 207 | + | 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 *attachmentLease | 221 | var lease *attachmentLease | ||
| 237 | - | if publication.targetCoordinator != nil && publication.old == nil { | 222 | + | if publication.targetCoordinator != nil { |
| 238 | var cleanup renderLifecycleCleanup | 223 | 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 admission | 243 | // Membership, currentSession, lease, generation, and both exact admission | ||
| 266 | // capabilities become visible as one frozen publication. The gates remain | 244 | // 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 retains | 256 | // 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) { | |||
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | 25 | sess.mu.Lock() | |
| 26 | - | visible := sess.client != nil && sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb | |
| 27 | 26 | if !tb.attention { | |
| 28 | 27 | tb.attentionAt = now | |
| 29 | 28 | } | |
| 30 | 29 | 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 | + | } | |
| 33 | 36 | } | |
| 34 | 37 | sess.mu.Unlock() | |
| 35 | 38 | ||
| @@ -55,8 +58,8 @@ func (d *Daemon) jumpAttentionForRole(sess *session, ac *attachedClient, token a | |||
| 55 | 58 | return nil | |
| 56 | 59 | } | |
| 57 | 60 | 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) | |
| 60 | 63 | d.invalidateRender(sess, ac, true, "attention.go") | |
| 61 | 64 | } | |
| 62 | 65 | return nil | |
| @@ -127,10 +130,7 @@ func oldestAttentionTabWithTime(sess *session) (int, time.Time, bool) { | |||
| 127 | 130 | } | |
| 128 | 131 | ||
| 129 | 132 | 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() { | |
| 134 | 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 { | |
| 149 | - | if tb == nil { | |
| 148 | + | func (s *session) ackAttention(tb *tab, ac *attachedClient, visible bool) bool { | |
| 149 | + | if tb == nil || ac == nil { | |
| 150 | 150 | return false | |
| 151 | 151 | } | |
| 152 | 152 | s.mu.Lock() | |
| 153 | 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 | 156 | return false | |
| 156 | 157 | } | |
| 157 | 158 | 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 = now | 27 | tb.attentionAt = now | ||
| 29 | } | 28 | } | ||
| 30 | tb.attention = true | 29 | tb.attention = true | ||
| 31 | - | if visible { | 30 | + | for _, attachment := range sess.snapshotAttachmentsLocked() { |
| 32 | - | tb.attentionVisiblePaint = true | 31 | + | 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 nil | 58 | 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 nil | 65 | 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 false | 150 | 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 false | 156 | 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 | |||
| 122 | 122 | // slice under sess.mu (daemon.go, resume.go), and nothing mutates the | |
| 123 | 123 | // backing array in place, so capturing the header here is safe. | |
| 124 | 124 | 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() | |
| 131 | 126 | ctx.PaneCWD = sess.cwd | |
| 132 | 127 | sess.mu.Unlock() | |
| 133 | - | if ac == nil || tb == nil { | |
| 128 | + | tb := sess.tabForAttachmentOrActive(nil) | |
| 129 | + | if len(attachments) == 0 || tb == nil { | |
| 134 | 130 | return ctx, env, false | |
| 135 | 131 | } | |
| 136 | 132 | tb.mu.Lock() | |
| @@ -413,11 +409,10 @@ func (d *Daemon) pokeSessionRender(sess *session) { | |||
| 413 | 409 | if sess == nil { | |
| 414 | 410 | return | |
| 415 | 411 | } | |
| 416 | - | sess.mu.Lock() | |
| 417 | - | ac := sess.client | |
| 418 | - | sess.mu.Unlock() | |
| 419 | 412 | // A headless session can still supply a picker preview to an attachment | |
| 420 | 413 | // owned by another session. Its coordinator fans this wake out to those | |
| 421 | 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 | } | |
| @@ -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 the | 122 | // 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.env | 124 | env := sess.env | ||
| 125 | - | active := sess.active | 125 | + | 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.cwd | 126 | 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, false | 130 | 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 | return | 410 | 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 attachment | 412 | // A headless session can still supply a picker preview to an attachment | ||
| 420 | // owned by another session. Its coordinator fans this wake out to those | 413 | // 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 | |||
| 632 | 632 | } | |
| 633 | 633 | }, | |
| 634 | 634 | 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 | + | } | |
| 640 | 643 | } | |
| 641 | - | attached.sendMu.Lock() | |
| 642 | - | ready := attached.output == nil || !attached.output.atCapacity() | |
| 643 | - | attached.sendMu.Unlock() | |
| 644 | - | return ready | |
| 644 | + | return false | |
| 645 | 645 | }, | |
| 646 | 646 | }) | |
| 647 | 647 | installAttachmentRenderCoordinator(entry, rc) | |
| @@ -655,10 +655,10 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess | |||
| 655 | 655 | // next client (with a different terminal theme) may never have reported. | |
| 656 | 656 | // Mirrors attachClient's reset loop: snapshot sess.tabs under sess.mu, | |
| 657 | 657 | // 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. | |
| 662 | 662 | func (d *Daemon) resetScreenDefaultColors(sess *session) { | |
| 663 | 663 | d.applyHostTheme(sess, nil, themeui.Theme{}, true) | |
| 664 | 664 | } | |
| @@ -718,7 +718,7 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi | |||
| 718 | 718 | // Drained before the early return below so a session without an active tab | |
| 719 | 719 | // cannot swallow the queue. | |
| 720 | 720 | d.drainPendingForFirstPaint(sess, ac) | |
| 721 | - | tb := sess.activeTab() | |
| 721 | + | tb := sess.tabForAttachmentOrActive(ac) | |
| 722 | 722 | if tb == nil { | |
| 723 | 723 | return | |
| 724 | 724 | } | |
| @@ -800,8 +800,8 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||
| 800 | 800 | ||
| 801 | 801 | // handleClientNotice maps the closed client-event enum to daemon-owned notice | |
| 802 | 802 | // 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 | |
| 805 | 805 | // state, and retain the routingMu -> sess.mu order used by attachment paths. | |
| 806 | 806 | func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) { | |
| 807 | 807 | if sess == nil || ac == nil { | |
| @@ -846,7 +846,7 @@ func (d *Daemon) handleClientNoticeForRole(token attachmentRoleToken, notice por | |||
| 846 | 846 | func (d *Daemon) handleClientNoticeDirect(sess *session, ac *attachedClient, notice ports.ClientNotice) { | |
| 847 | 847 | d.notices.routingMu.Lock() | |
| 848 | 848 | sess.mu.Lock() | |
| 849 | - | current := sess.client == ac | |
| 849 | + | _, current := sess.attachments[ac] | |
| 850 | 850 | sess.mu.Unlock() | |
| 851 | 851 | if !current { | |
| 852 | 852 | 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().client | 636 | + | 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 false | 639 | + | 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 and | 657 | // 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 | 658 | + | // 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 attached | 659 | + | // 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 tabs | 660 | + | // 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 tab | 718 | // 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 | return | 723 | 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 notice | 801 | // handleClientNotice maps the closed client-event enum to daemon-owned notice | ||
| 802 | // content. routingMu makes ownership validation and toast mutation one atomic | 802 | // content. routingMu makes ownership validation and toast mutation one atomic | ||
| 803 | - | // attachment-routing operation: replacement also takes routingMu before | 803 | + | // attachment-routing operation: attachment publication also takes routingMu |
| 804 | - | // publishing sess.client. Never retain sess.mu while touching notice or overlay | 804 | + | // 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 == ac | 849 | + | _, 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 | |||
| 64 | 64 | } | |
| 65 | 65 | if rc := sess.renderCoordinator(); rc != nil { | |
| 66 | 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 | 68 | sess.mu.Lock() | |
| 75 | 69 | ephemeral := sess.ephemeral | |
| @@ -150,7 +144,7 @@ func (d *Daemon) detachProxyOnSendError(p *proxySession, ac *attachedClient, fai | |||
| 150 | 144 | ||
| 151 | 145 | // finishProxyClientGone retires external client ownership after the exact proxy | |
| 152 | 146 | // 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 | 148 | func (d *Daemon) finishProxyClientGone(p *proxySession, ac *attachedClient, failed ports.Transport, explicit bool) { | |
| 155 | 149 | if rc := p.coordinator.Load(); rc != nil { | |
| 156 | 150 | 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.ephemeral | 69 | 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 proxy | 145 | // finishProxyClientGone retires external client ownership after the exact proxy | ||
| 152 | // role has been unpublished. Warm-timer publication remains with the caller so | 146 | // 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) { | |||
| 226 | 226 | default: | |
| 227 | 227 | } | |
| 228 | 228 | } | |
| 229 | - | ac := sess.client | |
| 229 | + | attachments := sess.snapshotAttachmentsLocked() | |
| 230 | 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() | |
| 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}) | |
| 247 | 238 | } | |
| 248 | 239 | } | |
| 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 { | |
| 252 | 241 | 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 | 245 | if !sess.clipboardWorkerRunning { | |
| 256 | 246 | sess.clipboardWorkerRunning = true | |
| 257 | 247 | 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.client | 229 | + | 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 | - | return | 237 | + | 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 | return | 241 | 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 = true | 246 | 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() { | |||
| 345 | 345 | } | |
| 346 | 346 | ||
| 347 | 347 | 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 { | |
| 352 | 350 | d.applyHostTheme(sess, nil, theme.Theme{}, true) | |
| 353 | 351 | return | |
| 354 | 352 | } | |
| 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 | + | } | |
| 360 | 360 | } | |
| @@ -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.client | 349 | + | 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 | return | 351 | 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 { | |||
| 186 | 186 | func resolveControlTarget(sess *session, kind command.TargetKind, request ports.CommandRequest) (*tab, *pane, uint16, string) { | |
| 187 | 187 | sess.mu.Lock() | |
| 188 | 188 | tabs := append([]*tab(nil), sess.tabs...) | |
| 189 | - | active := sess.active | |
| 190 | 189 | sess.mu.Unlock() | |
| 191 | 190 | if request.Self { | |
| 192 | 191 | for _, tb := range tabs { | |
| @@ -204,10 +203,10 @@ func resolveControlTarget(sess *session, kind command.TargetKind, request ports. | |||
| 204 | 203 | } | |
| 205 | 204 | return nil, nil, ports.ErrNoSuchTarget, "no such target tab" | |
| 206 | 205 | } | |
| 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" | |
| 209 | 208 | } | |
| 210 | - | tb := tabs[active] | |
| 209 | + | tb := tabs[0] | |
| 211 | 210 | if kind != command.TargetPane { | |
| 212 | 211 | return tb, nil, 0, "" | |
| 213 | 212 | } | |
| @@ -249,9 +248,10 @@ const ( | |||
| 249 | 248 | ) | |
| 250 | 249 | ||
| 251 | 250 | type daemonActionTarget struct { | |
| 252 | - | session *session | |
| 253 | - | tab *tab | |
| 254 | - | pane *pane | |
| 251 | + | session *session | |
| 252 | + | attachment *attachedClient | |
| 253 | + | tab *tab | |
| 254 | + | pane *pane | |
| 255 | 255 | } | |
| 256 | 256 | ||
| 257 | 257 | type daemonActionRequest struct { | |
| @@ -283,7 +283,7 @@ func resolveDaemonActionTargetForAttachment(sess *session, ac *attachedClient) d | |||
| 283 | 283 | tb.mu.Lock() | |
| 284 | 284 | pane := tb.focusedPane() | |
| 285 | 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 | 289 | // daemonActions is the daemon-owned mutation seam shared by palette and | |
| @@ -322,9 +322,9 @@ func (a daemonActions) Run(request daemonActionRequest) error { | |||
| 322 | 322 | _, err := a.d.focusDirAt(target.session, target.tab, target.pane, request.direction) | |
| 323 | 323 | return err | |
| 324 | 324 | case daemonActionNextTab: | |
| 325 | - | return a.switchRelative(target.session, 1) | |
| 325 | + | return a.switchRelative(target.session, target.attachment, 1) | |
| 326 | 326 | case daemonActionPreviousTab: | |
| 327 | - | return a.switchRelative(target.session, -1) | |
| 327 | + | return a.switchRelative(target.session, target.attachment, -1) | |
| 328 | 328 | case daemonActionRenameSession: | |
| 329 | 329 | return a.d.renameSession(target.session, request.name) | |
| 330 | 330 | case daemonActionRenameTab: | |
| @@ -363,9 +363,9 @@ func (d *Daemon) hasDaemonActionPaneTarget(target daemonActionTarget) bool { | |||
| 363 | 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 { | |
| 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) | |
| 369 | 369 | } | |
| 370 | 370 | return nil | |
| 371 | 371 | } | |
| @@ -398,10 +398,7 @@ func (e controlExec) runAction(request daemonActionRequest) error { | |||
| 398 | 398 | return err | |
| 399 | 399 | } | |
| 400 | 400 | 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") | |
| 405 | 402 | } | |
| 406 | 403 | return nil | |
| 407 | 404 | } | |
| @@ -455,6 +452,7 @@ func (e controlExec) MovePane(destinationSession, destinationTabID string) error | |||
| 455 | 452 | return errMovePaneInvalid | |
| 456 | 453 | } | |
| 457 | 454 | return e.d.movePane(movePaneRequest{ | |
| 455 | + | Client: e.target.attachment, | |
| 458 | 456 | Source: sessionMoveLocator(e.sess), | |
| 459 | 457 | SourceTabID: domain.TabStableID(e.tab.stableID), | |
| 460 | 458 | SourcePaneID: domain.PaneStableID(e.target.pane.stableID), | |
| @@ -472,6 +470,7 @@ func (e controlExec) MoveTab(destinationSession string) error { | |||
| 472 | 470 | return errMovePaneInvalid | |
| 473 | 471 | } | |
| 474 | 472 | return e.d.moveTab(moveTabRequest{ | |
| 473 | + | Client: e.target.attachment, | |
| 475 | 474 | Source: sessionMoveLocator(e.sess), | |
| 476 | 475 | SourceTabID: domain.TabStableID(e.tab.stableID), | |
| 477 | 476 | Destination: sessionMoveLocator(destination), | |
| @@ -661,8 +660,8 @@ func (e controlExec) ListTabs(asJSON bool) (string, error) { | |||
| 661 | 660 | } | |
| 662 | 661 | e.sess.mu.Lock() | |
| 663 | 662 | tabs := append([]*tab(nil), e.sess.tabs...) | |
| 664 | - | active := e.sess.active | |
| 665 | 663 | e.sess.mu.Unlock() | |
| 664 | + | active, _ := e.sess.tabIndexForAttachment(e.target.attachment) | |
| 666 | 665 | rows := make([]row, 0, len(tabs)) | |
| 667 | 666 | for i, tb := range tabs { | |
| 668 | 667 | tb.mu.Lock() | |
| @@ -690,7 +689,7 @@ func (e controlExec) ListPanes(asJSON bool) (string, error) { | |||
| 690 | 689 | } | |
| 691 | 690 | tb := e.tab | |
| 692 | 691 | if tb == nil { | |
| 693 | - | tb = e.sess.activeTab() | |
| 692 | + | tb = e.sess.tabForAttachmentOrActive(e.target.attachment) | |
| 694 | 693 | } | |
| 695 | 694 | if tb == nil { | |
| 696 | 695 | 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 *session | 251 | + | session *session |
| 253 | - | tab *tab | 252 | + | attachment *attachedClient |
| 254 | - | pane *pane | 253 | + | 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 and | 289 | // 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 err | 323 | 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 nil | 370 | return nil | ||
| 371 | } | 371 | } | ||
| @@ -398,10 +398,7 @@ func (e controlExec) runAction(request daemonActionRequest) error { | |||||
| 398 | return err | 398 | 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 nil | 403 | return nil | ||
| 407 | } | 404 | } | ||
| @@ -455,6 +452,7 @@ func (e controlExec) MovePane(destinationSession, destinationTabID string) error | |||||
| 455 | return errMovePaneInvalid | 452 | 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 errMovePaneInvalid | 470 | 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.tab | 690 | 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) { | |||
| 992 | 992 | State: ports.SessionRunning, | |
| 993 | 993 | Ephemeral: s.ephemeral, | |
| 994 | 994 | Tabs: uint16(len(s.tabs)), | |
| 995 | - | Attached: s.client != nil, | |
| 995 | + | Attached: len(s.attachments) != 0, | |
| 996 | 996 | } | |
| 997 | 997 | liveNames[s.name] = struct{}{} | |
| 998 | 998 | 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 | |||
| 413 | 413 | } | |
| 414 | 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 | 417 | for _, tb := range tabs { | |
| 418 | 418 | publishTiledPaneOwners(sess, tb) | |
| 419 | 419 | } | |
| @@ -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 | |||
| 182 | 182 | // until firstPaint keeps restored tabs cold and avoids launching children | |
| 183 | 183 | // merely because a tab was manipulated during teardown. | |
| 184 | 184 | 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 | + | } | |
| 188 | 189 | } | |
| 189 | 190 | if rc := sess.renderCoordinator(); rc != nil && rc.opts.onActivateTabAfterResize != nil { | |
| 190 | 191 | rc.opts.onActivateTabAfterResize(lease != nil) | |
| @@ -478,13 +479,13 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin | |||
| 478 | 479 | d.reapplyThemeSession(sess) | |
| 479 | 480 | d.startPaneGoroutines(sess, tb, p) | |
| 480 | 481 | if visible { | |
| 481 | - | sess.mu.Lock() | |
| 482 | - | ac := sess.client | |
| 483 | - | sess.mu.Unlock() | |
| 482 | + | attachments := sess.snapshotAttachments() | |
| 484 | 483 | tb.mu.Lock() | |
| 485 | 484 | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} | |
| 486 | 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 | 516 | return | |
| 516 | 517 | } | |
| 517 | 518 | 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 { | |
| 522 | 522 | 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 | + | } | |
| 527 | 527 | } | |
| 528 | 528 | return | |
| 529 | 529 | } | |
| @@ -182,9 +182,10 @@ func (d *Daemon) activateTabAfterResizeForLease(sess *session, tb *tab, outerRes | |||||
| 182 | // until firstPaint keeps restored tabs cold and avoids launching children | 182 | // 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.client | 186 | + | 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 | return | 516 | return | ||
| 516 | } | 517 | } | ||
| 517 | closeFloatingPane(p) | 518 | closeFloatingPane(p) | ||
| 518 | - | sess.mu.Lock() | 519 | + | attachments := sess.snapshotAttachments() |
| 519 | - | ac := sess.client | 520 | + | 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 | return | 528 | return | ||
| 529 | } | 529 | } | ||
M internal/usecase/daemon/move.go +2 -0
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | // immutable identity. Session names are advisory; the ID and incarnation are | |
| 11 | 11 | // the commit-time authority. | |
| 12 | 12 | type movePaneRequest struct { | |
| 13 | + | Client *attachedClient | |
| 13 | 14 | Source moveSessionLocator | |
| 14 | 15 | SourceTabID domain.TabStableID | |
| 15 | 16 | SourcePaneID domain.PaneStableID | |
| @@ -18,6 +19,7 @@ type movePaneRequest struct { | |||
| 18 | 19 | } | |
| 19 | 20 | ||
| 20 | 21 | type moveTabRequest struct { | |
| 22 | + | Client *attachedClient | |
| 21 | 23 | Source moveSessionLocator | |
| 22 | 24 | SourceTabID domain.TabStableID | |
| 23 | 25 | Destination moveSessionLocator | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | // immutable identity. Session names are advisory; the ID and incarnation are | 10 | // 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 moveSessionLocator | 14 | Source moveSessionLocator | ||
| 14 | SourceTabID domain.TabStableID | 15 | SourceTabID domain.TabStableID | ||
| 15 | SourcePaneID domain.PaneStableID | 16 | 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 moveSessionLocator | 23 | Source moveSessionLocator | ||
| 22 | SourceTabID domain.TabStableID | 24 | SourceTabID domain.TabStableID | ||
| 23 | Destination moveSessionLocator | 25 | Destination moveSessionLocator | ||
M internal/usecase/daemon/move_attachments.go +1 -17
| @@ -7,24 +7,8 @@ type frozenMoveAttachmentRetirement struct { | |||
| 7 | 7 | 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 | 10 | 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 | |
| 28 | 12 | } | |
| 29 | 13 | ||
| 30 | 14 | func prepareFrozenMoveAttachmentRetirementLocked(sess *session, admitted []*attachedClient, frozen frozenRoleEffectGates) (frozenMoveAttachmentRetirement, bool) { | |
| @@ -7,24 +7,8 @@ type frozenMoveAttachmentRetirement struct { | |||||
| 7 | clients []*attachedClient | 7 | 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 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 4 | 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 | 57 | 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 | 65 | return &movePaneAdmission{ | |
| 60 | 66 | source: source, | |
| 61 | 67 | destination: destination, | |
| 62 | 68 | sourceTab: sourceTab, | |
| 63 | 69 | destinationTab: destinationTab, | |
| 64 | 70 | movedPane: movedPane, | |
| 65 | - | sourceClient: source.client, | |
| 66 | - | destinationClient: destination.client, | |
| 67 | - | sourceSnatched: snapshotMoveSnatchedLocked(source), | |
| 71 | + | sourceClient: req.Client, | |
| 72 | + | destinationClient: nil, | |
| 73 | + | sourceSnatched: nil, | |
| 68 | 74 | sourceGeneration: sourceTab.layoutGeneration, | |
| 69 | 75 | destinationGeneration: destinationTab.layoutGeneration, | |
| 70 | 76 | finalSourceTab: len(source.tabs) == 1 && source.tabs[0] == sourceTab && | |
| 71 | 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 | 79 | }, nil | |
| 74 | 80 | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | 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, errMoveStaleTarget | 57 | 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 | }, nil | 79 | }, nil | ||
| 74 | } | 80 | } | ||
M internal/usecase/daemon/move_pane_commit.go +5 -11
| @@ -62,7 +62,7 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool { | |||
| 62 | 62 | return false | |
| 63 | 63 | } | |
| 64 | 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 | 66 | c.handoffReq.targetTabIndex < 0 || c.handoffReq.targetTabIndex >= len(c.destination.tabs) || | |
| 67 | 67 | c.destination.tabs[c.handoffReq.targetTabIndex] != c.destinationTab { | |
| 68 | 68 | return false | |
| @@ -102,7 +102,7 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool { | |||
| 102 | 102 | ||
| 103 | 103 | sourceWillEmpty := candidate.removeSourceTab && len(c.source.tabs) == 1 | |
| 104 | 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 | 106 | (c.sourceClient != nil) != c.handoffFrozen { | |
| 107 | 107 | return false | |
| 108 | 108 | } | |
| @@ -122,13 +122,7 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool { | |||
| 122 | 122 | } | |
| 123 | 123 | c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...) | |
| 124 | 124 | 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 | + | ||
| 132 | 126 | } else { | |
| 133 | 127 | c.sourceTab.tree = candidate.sourceTree | |
| 134 | 128 | c.sourceTab.bumpLayoutGenerationLocked() | |
| @@ -151,8 +145,8 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool { | |||
| 151 | 145 | c.syncCleanup.append(d.migratePaneSyncOwnerLocked(c.movedPane, oldOwner, newOwner)) | |
| 152 | 146 | if c.handoffResult.published.ac != nil { | |
| 153 | 147 | 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) | |
| 156 | 150 | } | |
| 157 | 151 | ||
| 158 | 152 | if candidate.removeSourceTab && len(c.source.tabs) == 0 { | |
| @@ -62,7 +62,7 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool { | |||||
| 62 | return false | 62 | 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 false | 68 | return false | ||
| @@ -102,7 +102,7 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool { | |||||
| 102 | 102 | ||||
| 103 | sourceWillEmpty := candidate.removeSourceTab && len(c.source.tabs) == 1 | 103 | 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 false | 107 | 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 = true | 124 | 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.sourceTree | 127 | 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.published | 147 | 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) { | |||
| 84 | 84 | p.unlockDispatch() | |
| 85 | 85 | p.reservation.Release() | |
| 86 | 86 | ||
| 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() | |
| 89 | 92 | } | |
| 93 | + | ||
| 90 | 94 | if !moveSessionRetired(d, p.source) { | |
| 91 | 95 | sourceLayoutTab := p.sourceTab | |
| 92 | 96 | if p.sourceTabRemoved { | |
| 93 | - | sourceLayoutTab = p.source.activeTab() | |
| 97 | + | sourceLayoutTab = p.source.tabForAttachmentOrActive(nil) | |
| 94 | 98 | } | |
| 95 | 99 | if sourceLayoutTab != nil && sourceLayoutTab != p.destinationTab { | |
| 96 | 100 | d.applyTabLayout(p.source, sourceLayoutTab) | |
| @@ -103,10 +107,10 @@ func (p movePanePostcommitPlan) execute(d *Daemon) { | |||
| 103 | 107 | if p.destinationMetadataValid { | |
| 104 | 108 | d.markCatalogueDirty(p.destinationMetadata) | |
| 105 | 109 | } | |
| 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") | |
| 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 | 114 | d.invalidateRender(p.source, p.sourceCleanupToken.ac, true, "move.go") | |
| 111 | 115 | } | |
| 112 | 116 | if p.handoffResult.published.ac != nil { | |
| @@ -146,20 +150,18 @@ func (p movePanePostcommitPlan) execute(d *Daemon) { | |||
| 146 | 150 | // were not transferred. It runs inside the move commit, after the moved pane | |
| 147 | 151 | // has already acquired its destination owner, and performs no external work. | |
| 148 | 152 | func retireEmptyMoveSessionLocked(sess *session, retirement frozenMoveAttachmentRetirement) []detachedAttachmentSnapshot { | |
| 149 | - | if sess == nil || sess.client != nil { | |
| 153 | + | if sess == nil || len(sess.attachments) != 0 { | |
| 150 | 154 | return nil | |
| 151 | 155 | } | |
| 152 | 156 | retired := make([]detachedAttachmentSnapshot, 0, len(retirement.clients)) | |
| 153 | 157 | for _, ac := range retirement.clients { | |
| 154 | 158 | retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: ac.transportSnapshot()}) | |
| 155 | - | delete(sess.snatched, ac) | |
| 156 | 159 | sess.unregisterAttachmentLocked(ac) | |
| 157 | 160 | ac.roleGeneration.Add(1) | |
| 158 | 161 | ac.setSession(nil) | |
| 159 | 162 | ac.invalidateFrozenRoleCapability() | |
| 160 | 163 | } | |
| 161 | 164 | sess.tabs = nil | |
| 162 | - | sess.active = -1 | |
| 163 | 165 | return retired | |
| 164 | 166 | } | |
| 165 | 167 | ||
| @@ -173,22 +175,14 @@ func moveSessionRetired(d *Daemon, sess *session) bool { | |||
| 173 | 175 | 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 | 178 | func sessionClientIs(sess *session, client *attachedClient) bool { | |
| 186 | 179 | if sess == nil || client == nil { | |
| 187 | 180 | return false | |
| 188 | 181 | } | |
| 189 | 182 | sess.mu.Lock() | |
| 190 | 183 | defer sess.mu.Unlock() | |
| 191 | - | return sess.client == client | |
| 184 | + | _, ok := sess.attachments[client] | |
| 185 | + | return ok | |
| 192 | 186 | } | |
| 193 | 187 | ||
| 194 | 188 | // 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.sourceTab | 95 | 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 pane | 150 | // 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 nil | 154 | 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 = nil | 164 | sess.tabs = nil | ||
| 162 | - | sess.active = -1 | |||
| 163 | return retired | 165 | return retired | ||
| 164 | } | 166 | } | ||
| 165 | 167 | ||||
| @@ -173,22 +175,14 @@ func moveSessionRetired(d *Daemon, sess *session) bool { | |||||
| 173 | return !live | 175 | 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 false | 180 | 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 == client | 184 | + | _, ok := sess.attachments[client] |
| 185 | + | return ok | |||
| 192 | } | 186 | } | ||
| 193 | 187 | ||||
| 194 | // retireEmptySessionAfterMove completes only source-owned lifecycle work. The | 188 | // 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 { | |||
| 16 | 16 | } | |
| 17 | 17 | sess.mu.Lock() | |
| 18 | 18 | 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) | |
| 21 | 21 | } | |
| 22 | 22 | tb := findMoveTabLocked(sess, source.TabID) | |
| 23 | 23 | if tb == nil { | |
| @@ -90,6 +90,7 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc | |||
| 90 | 90 | return errMovePaneInvalid | |
| 91 | 91 | } | |
| 92 | 92 | return d.movePane(movePaneRequest{ | |
| 93 | + | Client: source.Client, | |
| 93 | 94 | Source: source.Session, | |
| 94 | 95 | SourceTabID: source.TabID, | |
| 95 | 96 | SourcePaneID: source.PaneID, | |
| @@ -98,6 +99,7 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc | |||
| 98 | 99 | }) | |
| 99 | 100 | case pickerMoveTab: | |
| 100 | 101 | return d.moveTab(moveTabRequest{ | |
| 102 | + | Client: source.Client, | |
| 101 | 103 | Source: source.Session, | |
| 102 | 104 | SourceTabID: source.TabID, | |
| 103 | 105 | Destination: destination, | |
| @@ -121,10 +123,10 @@ func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*sess | |||
| 121 | 123 | return nil, nil | |
| 122 | 124 | } | |
| 123 | 125 | if intent == pickerMoveTab { | |
| 124 | - | if sess.active < 0 || sess.active >= len(sess.tabs) { | |
| 126 | + | if len(sess.tabs) == 0 { | |
| 125 | 127 | return nil, nil | |
| 126 | 128 | } | |
| 127 | - | return sess, sess.tabs[sess.active] | |
| 129 | + | return sess, sess.tabs[0] | |
| 128 | 130 | } | |
| 129 | 131 | if target.TabID != "" { | |
| 130 | 132 | 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 errMovePaneInvalid | 90 | 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, nil | 123 | 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, nil | 127 | 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 | |||
| 214 | 214 | return nil, errMoveStaleTarget | |
| 215 | 215 | } | |
| 216 | 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 | 218 | return nil, errMoveStaleTarget | |
| 219 | 219 | } | |
| 220 | - | destinationActive := destination.tabs[destination.active] | |
| 220 | + | destinationActive := destination.tabs[0] | |
| 221 | 221 | destinationActive.mu.Lock() | |
| 222 | 222 | destinationSize := destinationActive.size | |
| 223 | 223 | destinationActive.mu.Unlock() | |
| @@ -242,7 +242,7 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio | |||
| 242 | 242 | idx := indexMoveTabLocked(source, moved) | |
| 243 | 243 | return &moveTabAdmission{ | |
| 244 | 244 | tab: moved, sourceIndex: idx, | |
| 245 | - | sourceClient: source.client, sourceSnatched: snapshotMoveSnatchedLocked(source), | |
| 245 | + | sourceClient: req.Client, sourceSnatched: nil, | |
| 246 | 246 | layoutGeneration: moved.layoutGeneration, floatingState: moved.floating.state, | |
| 247 | 247 | floatingPane: moved.floating.pane, floatingGeneration: moved.floating.generation, | |
| 248 | 248 | panes: panes, destinationActive: destinationActive, destinationSize: destinationSize, | |
| @@ -280,7 +280,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool { | |||
| 280 | 280 | // precede moved.mu. The resize fences and ordered session locks keep topology | |
| 281 | 281 | // stable until the tab/pane checks below complete. | |
| 282 | 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 | 284 | return false | |
| 285 | 285 | } | |
| 286 | 286 | publication, err := d.validateAttachmentTransitionPrelocked(c.handoffReq) | |
| @@ -293,7 +293,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool { | |||
| 293 | 293 | } | |
| 294 | 294 | var retirement frozenMoveAttachmentRetirement | |
| 295 | 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 | 297 | (c.admission.sourceClient != nil) != c.handoffFrozen { | |
| 298 | 298 | return false | |
| 299 | 299 | } | |
| @@ -303,7 +303,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool { | |||
| 303 | 303 | 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 | 307 | return false | |
| 308 | 308 | } | |
| 309 | 309 | c.admission.destinationActive.mu.Lock() | |
| @@ -330,16 +330,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool { | |||
| 330 | 330 | ||
| 331 | 331 | idx := c.admission.sourceIndex | |
| 332 | 332 | 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 | + | ||
| 343 | 334 | c.destination.tabs = append(c.destination.tabs, moved) | |
| 344 | 335 | moved.size = c.admission.destinationSize | |
| 345 | 336 | moved.bumpLayoutGenerationLocked() | |
| @@ -367,7 +358,6 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool { | |||
| 367 | 358 | d.beforeMoveTabCommit() | |
| 368 | 359 | } | |
| 369 | 360 | if c.handoffPublication != nil { | |
| 370 | - | c.destination.active = len(c.destination.tabs) - 1 | |
| 371 | 361 | c.handoffResult = d.publishAttachmentTransitionPrelocked(c.handoffPublication) | |
| 372 | 362 | c.handoffPublication.unlockCoordinators() | |
| 373 | 363 | c.handoffPublication = nil | |
| @@ -380,8 +370,8 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool { | |||
| 380 | 370 | } | |
| 381 | 371 | if c.handoffResult.published.ac != nil { | |
| 382 | 372 | 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) | |
| 385 | 375 | } | |
| 386 | 376 | if len(c.source.tabs) == 0 { | |
| 387 | 377 | d.unregisterSessionLocked(c.source) | |
| @@ -214,10 +214,10 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio | |||||
| 214 | return nil, errMoveStaleTarget | 214 | 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, errMoveStaleTarget | 218 | 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.size | 222 | 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 topology | 280 | // 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 false | 284 | 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 frozenMoveAttachmentRetirement | 294 | 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 false | 298 | return false | ||
| 299 | } | 299 | } | ||
| @@ -303,7 +303,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool { | |||||
| 303 | return false | 303 | 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 false | 307 | 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.sourceIndex | 331 | 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.destinationSize | 335 | 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 = nil | 363 | 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.published | 372 | 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 | |||
| 53 | 53 | // overflowSourceEligible validates that directional overflow still originates | |
| 54 | 54 | // from the active tiled tab. The session-to-tab lock order matches tab overflow | |
| 55 | 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 | 57 | if sess == nil || expectedSource == nil { | |
| 58 | 58 | return false | |
| 59 | 59 | } | |
| 60 | 60 | sess.mu.Lock() | |
| 61 | 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 | 63 | return false | |
| 64 | 64 | } | |
| 65 | 65 | expectedSource.mu.Lock() | |
| @@ -137,15 +137,25 @@ type tabOverflowCandidate struct { | |||
| 137 | 137 | // prepareTabOverflow captures tab identities under the session lock and uses | |
| 138 | 138 | // EntryPane's pure lookup before any active-tab state is changed. | |
| 139 | 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 | 144 | if sess == nil || expectedSource == nil || (delta != -1 && delta != 1) { | |
| 141 | 145 | return tabOverflowCandidate{}, false | |
| 142 | 146 | } | |
| 143 | 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 | 149 | sess.mu.Unlock() | |
| 146 | 150 | return tabOverflowCandidate{}, false | |
| 147 | 151 | } | |
| 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 | + | } | |
| 149 | 159 | targetIndex := sourceIndex + delta | |
| 150 | 160 | if targetIndex < 0 || targetIndex >= len(sess.tabs) { | |
| 151 | 161 | sess.mu.Unlock() | |
| @@ -193,13 +203,17 @@ func (d *Daemon) prepareTabOverflow(sess *session, expectedSource *tab, dir layo | |||
| 193 | 203 | // The target layout and selected pane are revalidated while locked before the | |
| 194 | 204 | // focus and active-tab mutations become visible together. | |
| 195 | 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 | 210 | if sess == nil || candidate.source == nil || candidate.target == nil { | |
| 197 | 211 | return false | |
| 198 | 212 | } | |
| 199 | 213 | sess.mu.Lock() | |
| 200 | 214 | 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 { | |
| 203 | 217 | sess.mu.Unlock() | |
| 204 | 218 | return false | |
| 205 | 219 | } | |
| @@ -234,10 +248,10 @@ func (d *Daemon) commitTabOverflow(sess *session, candidate tabOverflowCandidate | |||
| 234 | 248 | } | |
| 235 | 249 | candidate.target.tree = targetTree | |
| 236 | 250 | candidate.target.bumpLayoutGenerationLocked() | |
| 237 | - | sess.active = targetIndex | |
| 238 | 251 | candidate.target.mu.Unlock() | |
| 239 | 252 | candidate.source.mu.Unlock() | |
| 240 | 253 | sess.mu.Unlock() | |
| 254 | + | sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID)) | |
| 241 | 255 | d.applyTabLayout(sess, candidate.target) | |
| 242 | 256 | return true | |
| 243 | 257 | } | |
| @@ -53,13 +53,13 @@ func resolveOverflow(dir layout.Direction, cfg domain.NavConfig, position, count | |||||
| 53 | // overflowSourceEligible validates that directional overflow still originates | 53 | // overflowSourceEligible validates that directional overflow still originates | ||
| 54 | // from the active tiled tab. The session-to-tab lock order matches tab overflow | 54 | // 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 false | 58 | 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 false | 63 | 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 uses | 137 | // 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{}, false | 145 | 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{}, false | 150 | return tabOverflowCandidate{}, false | ||
| 147 | } | 151 | } | ||
| 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 | + | } | |||
| 149 | targetIndex := sourceIndex + delta | 159 | 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 the | 203 | // 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 false | 211 | return false | ||
| 198 | } | 212 | } | ||
| 199 | sess.mu.Lock() | 213 | sess.mu.Lock() | ||
| 200 | targetIndex := candidate.sourceIndex + candidate.delta | 214 | 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 false | 218 | return false | ||
| 205 | } | 219 | } | ||
| @@ -234,10 +248,10 @@ func (d *Daemon) commitTabOverflow(sess *session, candidate tabOverflowCandidate | |||||
| 234 | } | 248 | } | ||
| 235 | candidate.target.tree = targetTree | 249 | 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 true | 256 | 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 | |||
| 267 | 267 | if sess != nil { | |
| 268 | 268 | d.notices.routingMu.Lock() | |
| 269 | 269 | sess.mu.Lock() | |
| 270 | - | ac := sess.client | |
| 270 | + | attachments := sess.snapshotAttachmentsLocked() | |
| 271 | 271 | sess.mu.Unlock() | |
| 272 | - | if ac == nil { | |
| 272 | + | if len(attachments) == 0 { | |
| 273 | 273 | d.notices.queueGlobal(n) | |
| 274 | 274 | d.notices.routingMu.Unlock() | |
| 275 | 275 | return | |
| @@ -277,11 +277,14 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No | |||
| 277 | 277 | if d.notices.beforeSessionDelivery != nil { | |
| 278 | 278 | d.notices.beforeSessionDelivery() | |
| 279 | 279 | } | |
| 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 | + | } | |
| 283 | 286 | d.notices.routingMu.Unlock() | |
| 284 | - | if published { | |
| 287 | + | for _, ac := range published { | |
| 285 | 288 | d.repaintForNotice(ac) | |
| 286 | 289 | } | |
| 287 | 290 | return | |
| @@ -308,14 +311,12 @@ func (d *Daemon) deliverGlobal(n domain.Notification) { | |||
| 308 | 311 | } | |
| 309 | 312 | targets := make([]*attachedClient, 0, len(sessions)) | |
| 310 | 313 | 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) | |
| 317 | 319 | } | |
| 318 | - | targets = append(targets, ac) | |
| 319 | 320 | } | |
| 320 | 321 | if len(targets) == 0 { | |
| 321 | 322 | if d.notices.beforeQueueGlobal != nil { | |
| @@ -338,7 +339,7 @@ func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) { | |||
| 338 | 339 | d.notices.routingMu.Lock() | |
| 339 | 340 | ||
| 340 | 341 | sess.mu.Lock() | |
| 341 | - | current := sess.client == ac | |
| 342 | + | current := attachmentRegisteredLocked(sess, ac) | |
| 342 | 343 | sess.mu.Unlock() | |
| 343 | 344 | if !current { | |
| 344 | 345 | 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.client | 270 | + | 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 | return | 275 | 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 before | 280 | + | 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 | return | 290 | 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.client | 316 | + | 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 == ac | 342 | + | 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 { | |||
| 190 | 190 | } | |
| 191 | 191 | return nil | |
| 192 | 192 | } | |
| 193 | - | tb := sess.activeTab() | |
| 193 | + | target := resolveDaemonActionTargetForAttachment(sess, ac) | |
| 194 | + | tb := target.tab | |
| 194 | 195 | if tb == nil { | |
| 195 | 196 | return layout.ErrNotFound | |
| 196 | 197 | } | |
| @@ -254,7 +255,7 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool { | |||
| 254 | 255 | sess.mu.Unlock() | |
| 255 | 256 | return true | |
| 256 | 257 | } | |
| 257 | - | ac := sess.client | |
| 258 | + | attachments := sess.snapshotAttachmentsLocked() | |
| 258 | 259 | sessionName := sess.name | |
| 259 | 260 | tb.mu.Lock() | |
| 260 | 261 | if p.owner.Load() != owner { | |
| @@ -295,7 +296,7 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool { | |||
| 295 | 296 | return true | |
| 296 | 297 | } | |
| 297 | 298 | d.applyTabLayout(sess, tb) | |
| 298 | - | if ac != nil { | |
| 299 | + | for _, ac := range attachments { | |
| 299 | 300 | ac.overlays.clearCopyModeForPane(p) | |
| 300 | 301 | ac.pruneCaptureFrames(p) | |
| 301 | 302 | } | |
| @@ -304,7 +305,7 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool { | |||
| 304 | 305 | } | |
| 305 | 306 | closePaneProcess(p) | |
| 306 | 307 | d.log.Info("pane closed", "session", sessionName, "pane", p.id) | |
| 307 | - | if ac != nil { | |
| 308 | + | for _, ac := range attachments { | |
| 308 | 309 | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| 309 | 310 | } | |
| 310 | 311 | return true | |
| @@ -314,11 +315,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||
| 314 | 315 | if tb == nil { | |
| 315 | 316 | return layout.ErrNotFound | |
| 316 | 317 | } | |
| 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. | |
| 322 | 319 | tb.mu.Lock() | |
| 323 | 320 | p := tb.panes[id] | |
| 324 | 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 | 372 | if !errors.Is(err, errNoNeighbor) || target.tab == nil { | |
| 376 | 373 | return err | |
| 377 | 374 | } | |
| 378 | - | if !overflowSourceEligible(sess, target.tab) { | |
| 375 | + | if !overflowSourceEligible(sess, ac, target.tab) { | |
| 379 | 376 | return errNoNeighbor | |
| 380 | 377 | } | |
| 381 | 378 | ||
| @@ -390,19 +387,17 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio | |||
| 390 | 387 | return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget) | |
| 391 | 388 | } | |
| 392 | 389 | ||
| 393 | - | sess.mu.Lock() | |
| 394 | - | position, count := sess.active, len(sess.tabs) | |
| 395 | - | sess.mu.Unlock() | |
| 390 | + | position, count := sess.tabIndexForAttachment(ac) | |
| 396 | 391 | step := resolveOverflow(dir, cfg, position, count) | |
| 397 | 392 | if step.kind != overflowTabs { | |
| 398 | 393 | return err | |
| 399 | 394 | } | |
| 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) { | |
| 402 | 397 | return errNoNeighbor | |
| 403 | 398 | } | |
| 404 | - | d.activateTab(sess, candidate.target) | |
| 405 | 399 | if ac != nil { | |
| 400 | + | sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID)) | |
| 406 | 401 | d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "pane_actions.go") | |
| 407 | 402 | } | |
| 408 | 403 | return nil | |
| @@ -190,7 +190,8 @@ func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error { | |||||
| 190 | } | 190 | } | ||
| 191 | return nil | 191 | 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.ErrNotFound | 196 | 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 true | 256 | return true | ||
| 256 | } | 257 | } | ||
| 257 | - | ac := sess.client | 258 | + | attachments := sess.snapshotAttachmentsLocked() |
| 258 | sessionName := sess.name | 259 | 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 true | 296 | 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 true | 311 | 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.ErrNotFound | 316 | 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 err | 373 | return err | ||
| 377 | } | 374 | } | ||
| 378 | - | if !overflowSourceEligible(sess, target.tab) { | 375 | + | if !overflowSourceEligible(sess, ac, target.tab) { |
| 379 | return errNoNeighbor | 376 | 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 err | 393 | 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 errNoNeighbor | 397 | 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 nil | 403 | 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 | |||
| 151 | 151 | if sess == nil { | |
| 152 | 152 | return fallback | |
| 153 | 153 | } | |
| 154 | - | tb := sess.activeTab() | |
| 154 | + | tb := sess.tabForAttachmentOrActive(nil) | |
| 155 | 155 | if tb == nil { | |
| 156 | 156 | return fallback | |
| 157 | 157 | } | |
| @@ -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 fallback | 152 | return fallback | ||
| 153 | } | 153 | } | ||
| 154 | - | tb := sess.activeTab() | 154 | + | tb := sess.tabForAttachmentOrActive(nil) |
| 155 | if tb == nil { | 155 | if tb == nil { | ||
| 156 | return fallback | 156 | return fallback | ||
| 157 | } | 157 | } | ||
M internal/usecase/daemon/picker.go +15 -11
| @@ -489,9 +489,7 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | |||
| 489 | 489 | continue | |
| 490 | 490 | } | |
| 491 | 491 | sess.mu.Lock() | |
| 492 | - | if sess.client != nil { | |
| 493 | - | clients = append(clients, sess.client) | |
| 494 | - | } | |
| 492 | + | clients = append(clients, sess.snapshotAttachmentsLocked()...) | |
| 495 | 493 | sess.mu.Unlock() | |
| 496 | 494 | } | |
| 497 | 495 | d.mu.Unlock() | |
| @@ -759,7 +757,7 @@ func (d *Daemon) discardUnattachedProxyGeneration(proxy *proxySession, generatio | |||
| 759 | 757 | return false | |
| 760 | 758 | } | |
| 761 | 759 | proxy.sessionCore.mu.Lock() | |
| 762 | - | if proxy.client != nil { | |
| 760 | + | if len(proxy.attachments) != 0 { | |
| 763 | 761 | proxy.sessionCore.mu.Unlock() | |
| 764 | 762 | d.mu.Unlock() | |
| 765 | 763 | return false | |
| @@ -879,12 +877,12 @@ func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient, | |||
| 879 | 877 | if sourceToken != nil { | |
| 880 | 878 | fresh, admitted := ac.beginRoleEffect(transition.published) | |
| 881 | 879 | if admitted { | |
| 882 | - | d.activateTab(from, from.activeTab()) | |
| 880 | + | d.activateTabAfterResizeForLease(from, from.tabForAttachmentOrActive(ac), false, ac, transition.published.lease) | |
| 883 | 881 | d.invalidateRender(from, ac, true, "picker.go") | |
| 884 | 882 | fresh.End() | |
| 885 | 883 | } | |
| 886 | 884 | } else { | |
| 887 | - | d.activateTab(from, from.activeTab()) | |
| 885 | + | d.activateTabAfterResizeForLease(from, from.tabForAttachmentOrActive(ac), false, ac, nil) | |
| 888 | 886 | d.invalidateRender(from, ac, true, "picker.go") | |
| 889 | 887 | } | |
| 890 | 888 | } else { | |
| @@ -923,10 +921,13 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||
| 923 | 921 | if sourceToken == nil { | |
| 924 | 922 | targetSess.mu.Lock() | |
| 925 | 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 | 929 | return nil, attachmentTransitionResult{}, false | |
| 928 | 930 | } | |
| 929 | - | targetSess.active = target.TabIndex | |
| 930 | 931 | return targetSess, attachmentTransitionResult{}, true | |
| 931 | 932 | } | |
| 932 | 933 | d.mu.Unlock() | |
| @@ -941,12 +942,14 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||
| 941 | 942 | } | |
| 942 | 943 | ||
| 943 | 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 | 947 | unlock() | |
| 946 | 948 | return nil, attachmentTransitionResult{}, false | |
| 947 | 949 | } | |
| 948 | 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 | 953 | unlock() | |
| 951 | 954 | return nil, attachmentTransitionResult{}, false | |
| 952 | 955 | } | |
| @@ -1034,7 +1037,8 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||
| 1034 | 1037 | } | |
| 1035 | 1038 | ||
| 1036 | 1039 | from.mu.Lock() | |
| 1037 | - | if from.client != ac { | |
| 1040 | + | _, attached := from.attachments[ac] | |
| 1041 | + | if !attached { | |
| 1038 | 1042 | from.mu.Unlock() | |
| 1039 | 1043 | return nil, attachmentTransitionResult{}, false, nil | |
| 1040 | 1044 | } | |
| @@ -489,9 +489,7 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | |||||
| 489 | continue | 489 | 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 false | 757 | 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 false | 763 | 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{}, false | 929 | return nil, attachmentTransitionResult{}, false | ||
| 928 | } | 930 | } | ||
| 929 | - | targetSess.active = target.TabIndex | |||
| 930 | return targetSess, attachmentTransitionResult{}, true | 931 | 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{}, false | 948 | 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{}, false | 954 | 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, nil | 1043 | 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 | |||
| 239 | 239 | } | |
| 240 | 240 | 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 | 243 | return err | |
| 244 | 244 | } | |
| 245 | 245 | ||
| 246 | - | sess.mu.Lock() | |
| 247 | - | position, count := sess.active, len(sess.tabs) | |
| 248 | - | sess.mu.Unlock() | |
| 246 | + | position, count := sess.tabIndexForAttachment(ac) | |
| 249 | 247 | step := resolveOverflow(dir, proxiedNavConfig(d.currentNavConfig()), position, count) | |
| 250 | 248 | if step.kind != overflowTabs { | |
| 251 | 249 | return errNoNeighbor | |
| 252 | 250 | } | |
| 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) { | |
| 255 | 253 | return errNoNeighbor | |
| 256 | 254 | } | |
| 257 | - | d.activateTab(sess, candidate.target) | |
| 255 | + | sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID)) | |
| 258 | 256 | if ac != nil { | |
| 259 | 257 | d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "proxy_input.go") | |
| 260 | 258 | } | |
| @@ -239,22 +239,20 @@ func (d *Daemon) focusDirProxied(sess *session, ac *attachedClient, dir layout.D | |||||
| 239 | } | 239 | } | ||
| 240 | return nil | 240 | 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 err | 243 | 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 errNoNeighbor | 249 | 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 errNoNeighbor | 253 | 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 | |||
| 66 | 66 | return false | |
| 67 | 67 | } | |
| 68 | 68 | p.sessionCore.mu.Lock() | |
| 69 | - | if p.client != ac { | |
| 69 | + | if !attachmentRegisteredLocked(attachmentSession(p), ac) { | |
| 70 | 70 | p.sessionCore.mu.Unlock() | |
| 71 | 71 | d.mu.Unlock() | |
| 72 | 72 | return false | |
| @@ -100,7 +100,7 @@ func (d *Daemon) armProxyWarm(p *proxySession) bool { | |||
| 100 | 100 | return false | |
| 101 | 101 | } | |
| 102 | 102 | p.sessionCore.mu.Lock() | |
| 103 | - | if p.client != nil { | |
| 103 | + | if len(p.attachments) != 0 { | |
| 104 | 104 | p.sessionCore.mu.Unlock() | |
| 105 | 105 | d.mu.Unlock() | |
| 106 | 106 | return false | |
| @@ -126,7 +126,7 @@ func (d *Daemon) armProxyWarm(p *proxySession) bool { | |||
| 126 | 126 | valid := d.sessions[p.id] == p && !d.closing | |
| 127 | 127 | if valid { | |
| 128 | 128 | p.sessionCore.mu.Lock() | |
| 129 | - | valid = p.client == nil | |
| 129 | + | valid = len(p.attachments) == 0 | |
| 130 | 130 | if valid { | |
| 131 | 131 | p.mu.Lock() | |
| 132 | 132 | valid = p.generation == generation && p.warm == old | |
| @@ -174,7 +174,7 @@ func (d *Daemon) expireWarmProxy(p *proxySession, token *proxyWarmTimer) bool { | |||
| 174 | 174 | return false | |
| 175 | 175 | } | |
| 176 | 176 | p.sessionCore.mu.Lock() | |
| 177 | - | if p.client != nil { | |
| 177 | + | if len(p.attachments) != 0 { | |
| 178 | 178 | p.sessionCore.mu.Unlock() | |
| 179 | 179 | d.mu.Unlock() | |
| 180 | 180 | return false | |
| @@ -245,12 +245,14 @@ func (d *Daemon) withProxyLocked(p *proxySession, update func() bool) bool { | |||
| 245 | 245 | p.sessionCore.mu.Lock() | |
| 246 | 246 | p.mu.Lock() | |
| 247 | 247 | current := update() | |
| 248 | - | ac := p.client | |
| 248 | + | attachments := append([]*attachedClient(nil), p.sessionCore.snapshotAttachmentsLocked()...) | |
| 249 | 249 | p.mu.Unlock() | |
| 250 | 250 | p.sessionCore.mu.Unlock() | |
| 251 | 251 | 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 | + | } | |
| 254 | 256 | } | |
| 255 | 257 | return current | |
| 256 | 258 | } | |
| @@ -317,7 +319,7 @@ func (d *Daemon) markProxyReplaced(p *proxySession, generation uint64, transport | |||
| 317 | 319 | p.warm.generation = p.generation | |
| 318 | 320 | } | |
| 319 | 321 | } | |
| 320 | - | needsWarm = current && p.client == nil && p.warm == nil | |
| 322 | + | needsWarm = current && len(p.attachments) == 0 && p.warm == nil | |
| 321 | 323 | return current | |
| 322 | 324 | }) | |
| 323 | 325 | if needsWarm { | |
| @@ -66,7 +66,7 @@ func (d *Daemon) cancelProxyWarmForAttachment(p *proxySession, ac *attachedClien | |||||
| 66 | return false | 66 | 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 false | 72 | return false | ||
| @@ -100,7 +100,7 @@ func (d *Daemon) armProxyWarm(p *proxySession) bool { | |||||
| 100 | return false | 100 | 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 false | 106 | return false | ||
| @@ -126,7 +126,7 @@ func (d *Daemon) armProxyWarm(p *proxySession) bool { | |||||
| 126 | valid := d.sessions[p.id] == p && !d.closing | 126 | 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 == nil | 129 | + | 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 == old | 132 | valid = p.generation == generation && p.warm == old | ||
| @@ -174,7 +174,7 @@ func (d *Daemon) expireWarmProxy(p *proxySession, token *proxyWarmTimer) bool { | |||||
| 174 | return false | 174 | 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 false | 180 | 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.client | 248 | + | 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 current | 257 | return current | ||
| 256 | } | 258 | } | ||
| @@ -317,7 +319,7 @@ func (d *Daemon) markProxyReplaced(p *proxySession, generation uint64, transport | |||||
| 317 | p.warm.generation = p.generation | 319 | p.warm.generation = p.generation | ||
| 318 | } | 320 | } | ||
| 319 | } | 321 | } | ||
| 320 | - | needsWarm = current && p.client == nil && p.warm == nil | 322 | + | needsWarm = current && len(p.attachments) == 0 && p.warm == nil |
| 321 | return current | 323 | 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 | |||
| 659 | 659 | if p == nil || !p.currentLinkGeneration(generation) { | |
| 660 | 660 | return nil | |
| 661 | 661 | } | |
| 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 { | |
| 666 | 668 | return nil | |
| 667 | 669 | } | |
| 668 | 670 | 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 nil | 660 | return nil | ||
| 661 | } | 661 | } | ||
| 662 | - | p.sessionCore.mu.Lock() | 662 | + | attachments := snapshotAttachmentSession(p) |
| 663 | - | ac := p.client | 663 | + | 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 nil | 668 | 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 | |||
| 91 | 91 | return nil | |
| 92 | 92 | } | |
| 93 | 93 | ||
| 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 | + | } | |
| 98 | 97 | return nil | |
| 99 | 98 | } | |
| 100 | 99 | ||
| @@ -91,10 +91,9 @@ func (d *Daemon) handleProxyScreenUpdate(p *proxySession, generation uint64, upd | |||||
| 91 | return nil | 91 | return nil | ||
| 92 | } | 92 | } | ||
| 93 | 93 | ||||
| 94 | - | p.sessionCore.mu.Lock() | 94 | + | for _, ac := range snapshotAttachmentSession(p) { |
| 95 | - | ac := p.client | 95 | + | 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 nil | 97 | 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 | |||
| 122 | 122 | p.mu.Unlock() | |
| 123 | 123 | ||
| 124 | 124 | 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) { | |
| 129 | 126 | d.invalidateRender(p, ac, false, "proxy_session.go") | |
| 130 | 127 | } | |
| 131 | 128 | return true, nil | |
| @@ -156,7 +153,7 @@ func (p *proxySession) snapshotView(opts viewOptions) sessionView { | |||
| 156 | 153 | cannotAcceptMoves: true, | |
| 157 | 154 | } | |
| 158 | 155 | p.sessionCore.mu.Lock() | |
| 159 | - | view.attached = p.client != nil | |
| 156 | + | view.attached = len(p.attachments) != 0 | |
| 160 | 157 | p.sessionCore.mu.Unlock() | |
| 161 | 158 | if opts.tabDetails { | |
| 162 | 159 | 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, nil | 128 | 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 != nil | 156 | + | 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 { | |||
| 26 | 26 | return false | |
| 27 | 27 | } | |
| 28 | 28 | 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 | |
| 31 | 31 | sess.mu.Unlock() | |
| 32 | 32 | ||
| 33 | 33 | // The normal attached render path needs no cross-session picker lookup. | |
| @@ -63,17 +63,16 @@ func (d *Daemon) tabIsPickerPreview(tb *tab) bool { | |||
| 63 | 63 | sessions := localSessionsSnapshot(d.sessions) | |
| 64 | 64 | d.mu.Unlock() | |
| 65 | 65 | 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 | + | } | |
| 77 | 76 | } | |
| 78 | 77 | } | |
| 79 | 78 | return false | |
| @@ -320,7 +319,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, | |||
| 320 | 319 | } | |
| 321 | 320 | var tb *tab | |
| 322 | 321 | if local { | |
| 323 | - | tb = sess.activeTab() | |
| 322 | + | tb = sess.tabForAttachmentOrActive(ac) | |
| 324 | 323 | if tb == nil { | |
| 325 | 324 | return | |
| 326 | 325 | } | |
| @@ -331,7 +330,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, | |||
| 331 | 330 | // published identity while holding it so a deadline captured before an | |
| 332 | 331 | // attach/replace cannot emit on either the old or new output chain. | |
| 333 | 332 | entry.core().mu.Lock() | |
| 334 | - | owned := entry.core().client == ac | |
| 333 | + | _, owned := entry.core().attachments[ac] | |
| 335 | 334 | entry.core().mu.Unlock() | |
| 336 | 335 | if !owned || ac.currentAttachmentSession() != entry { | |
| 337 | 336 | ac.sendMu.Unlock() | |
| @@ -382,12 +381,12 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, | |||
| 382 | 381 | if statusFeedback == "" && overlays.resizeActive { | |
| 383 | 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 | 385 | applied := ac.getAppliedTheme() | |
| 387 | 386 | bars.theme = applied.Raw | |
| 388 | 387 | if local { | |
| 389 | 388 | attentionVisible := pulseVisible(bars.attentionFrame) | |
| 390 | - | repaintAttachedClients = sess.ackAttention(tb, attentionVisible) | |
| 389 | + | repaintAttachedClients = sess.ackAttention(tb, ac, attentionVisible) | |
| 391 | 390 | } | |
| 392 | 391 | ||
| 393 | 392 | paletteCfg := d.currentPaletteConfig() | |
| @@ -26,8 +26,8 @@ func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool { | |||||
| 26 | return false | 26 | 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] == tb | 29 | + | active := sess.attachmentViewsTabLocked(tb) |
| 30 | - | attached := sess.client != nil | 30 | + | 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.client | 67 | + | if ac == nil || ac.overlays == nil { |
| 68 | - | sess.mu.Unlock() | 68 | + | continue |
| 69 | - | if ac == nil || ac.overlays == nil { | 69 | + | } |
| 70 | - | continue | 70 | + | 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 == tb | 73 | + | if preview { |
| 74 | - | ac.overlays.pickerMu.Unlock() | 74 | + | return true |
| 75 | - | if preview { | 75 | + | } |
| 76 | - | return true | |||
| 77 | } | 76 | } | ||
| 78 | } | 77 | } | ||
| 79 | return false | 78 | return false | ||
| @@ -320,7 +319,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, | |||||
| 320 | } | 319 | } | ||
| 321 | var tb *tab | 320 | 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 | return | 324 | 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 an | 330 | // 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 == ac | 333 | + | _, 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.Raw | 386 | 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( | |||
| 212 | 212 | return nil, false | |
| 213 | 213 | } | |
| 214 | 214 | 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] | |
| 219 | 216 | sess.mu.Unlock() | |
| 220 | 217 | if !owned { | |
| 221 | 218 | return nil, false | |
| 222 | 219 | } | |
| 223 | 220 | tb, _ := sess.paneForAttachment(ac) | |
| 224 | 221 | if tb == nil { | |
| 225 | - | tb = sess.activeTab() | |
| 222 | + | tb = sess.tabForAttachmentOrActive(ac) | |
| 226 | 223 | } | |
| 227 | 224 | if tb == nil { | |
| 228 | 225 | return nil, false | |
| @@ -212,17 +212,14 @@ func captureLocalPrimaryRenderState( | |||||
| 212 | return nil, false | 212 | return nil, false | ||
| 213 | } | 213 | } | ||
| 214 | sess.mu.Lock() | 214 | sess.mu.Lock() | ||
| 215 | - | owned := sess.client == ac | 215 | + | _, 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, false | 218 | 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, false | 225 | return nil, false | ||
M internal/usecase/daemon/render_coordinator.go +15 -3
| @@ -747,7 +747,14 @@ func (c *renderCoordinator) waitForTimerWorkers() { c.supervisor.wait() } | |||
| 747 | 747 | // compositor; attached sessions always schedule through their coordinator. | |
| 748 | 748 | func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, reset bool, producer string) { | |
| 749 | 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 | 758 | return | |
| 752 | 759 | } | |
| 753 | 760 | if ac != nil { | |
| @@ -760,8 +767,13 @@ func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, r | |||
| 760 | 767 | // the required first full frame never depends on a debounce timer. | |
| 761 | 768 | func (d *Daemon) invalidateRenderNow(entry attachmentSession, ac *attachedClient, reset bool, producer string) { | |
| 762 | 769 | 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 | + | } | |
| 765 | 777 | return | |
| 766 | 778 | } | |
| 767 | 779 | 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 | return | 758 | 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 | return | 777 | 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 { | |||
| 52 | 52 | urgent bool | |
| 53 | 53 | coalesced int | |
| 54 | 54 | // 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. | |
| 57 | 57 | lease *attachmentLease | |
| 58 | 58 | // watchdog marks a flush forced by the synchronized-output watchdog. | |
| 59 | 59 | watchdog bool | |
| @@ -52,8 +52,8 @@ type renderWake struct { | |||||
| 52 | urgent bool | 52 | urgent bool | ||
| 53 | coalesced int | 53 | coalesced int | ||
| 54 | // lease is snapshotted under coordinator ownership. The wake consumer must | 54 | // lease is snapshotted under coordinator ownership. The wake consumer must | ||
| 55 | - | // compose only for this exact active attachment incarnation, never by | 55 | + | // 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 *attachmentLease | 57 | 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 bool | 59 | 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 | |||
| 459 | 459 | return false | |
| 460 | 460 | } | |
| 461 | 461 | entry.core().mu.Lock() | |
| 462 | - | owned := entry.core().client == ac | |
| 462 | + | _, owned := entry.core().attachments[ac] | |
| 463 | 463 | entry.core().mu.Unlock() | |
| 464 | 464 | if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry { | |
| 465 | 465 | ac.sendMu.Unlock() | |
| @@ -459,7 +459,7 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c | |||||
| 459 | return false | 459 | return false | ||
| 460 | } | 460 | } | ||
| 461 | entry.core().mu.Lock() | 461 | entry.core().mu.Lock() | ||
| 462 | - | owned := entry.core().client == ac | 462 | + | _, 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 { | |||
| 47 | 47 | if sess == nil || ac == nil { | |
| 48 | 48 | return layout.ErrNotFound | |
| 49 | 49 | } | |
| 50 | - | target := resolveDaemonActionTarget(sess) | |
| 50 | + | target := resolveDaemonActionTargetForAttachment(sess, ac) | |
| 51 | 51 | if target.tab == nil || target.pane == nil { | |
| 52 | 52 | return layout.ErrNotFound | |
| 53 | 53 | } | |
| @@ -131,7 +131,7 @@ func (d *Daemon) handleResizeInput(ac *attachedClient, data []byte) { | |||
| 131 | 131 | return | |
| 132 | 132 | } | |
| 133 | 133 | for _, request := range requests { | |
| 134 | - | request.target = resolveDaemonActionTarget(sess) | |
| 134 | + | request.target = resolveDaemonActionTargetForAttachment(sess, ac) | |
| 135 | 135 | if err := (daemonActions{d: d}).Run(request); err != nil { | |
| 136 | 136 | d.reportError(sess, resizeUserError(err)) | |
| 137 | 137 | 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.ErrNotFound | 48 | 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.ErrNotFound | 52 | return layout.ErrNotFound | ||
| 53 | } | 53 | } | ||
| @@ -131,7 +131,7 @@ func (d *Daemon) handleResizeInput(ac *attachedClient, data []byte) { | |||||
| 131 | return | 131 | 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 | continue | 137 | continue | ||
M internal/usecase/daemon/resume.go +12 -15
| @@ -37,8 +37,8 @@ func (d *Daemon) nextResumeTokenLocked() uint64 { | |||
| 37 | 37 | // can wait out the detach→park gap. Callers must publish this before clearing | |
| 38 | 38 | // the live seat so resume never observes both registries empty for a still-valid | |
| 39 | 39 | // 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 | |
| 42 | 42 | // cleanup. Returns the resume token, or 0 when parking cannot be advertised. | |
| 43 | 43 | func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 { | |
| 44 | 44 | if sess == nil || ac == nil || !ac.resumeCapable { | |
| @@ -50,10 +50,9 @@ func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 { | |||
| 50 | 50 | return 0 | |
| 51 | 51 | } | |
| 52 | 52 | sess.mu.Lock() | |
| 53 | - | role := sess.attachmentRoleLocked(ac) | |
| 54 | - | owner := role == attachmentActive || role == attachmentSnatched | |
| 53 | + | registered := attachmentRegisteredLocked(sess, ac) | |
| 55 | 54 | sess.mu.Unlock() | |
| 56 | - | if !owner { | |
| 55 | + | if !registered { | |
| 57 | 56 | return 0 | |
| 58 | 57 | } | |
| 59 | 58 | if ac.resumeToken == 0 { | |
| @@ -96,8 +95,8 @@ func (d *Daemon) clearParkingInFlight(token uint64, ac *attachedClient) { | |||
| 96 | 95 | } | |
| 97 | 96 | ||
| 98 | 97 | // 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 | |
| 101 | 100 | // owns park publication. | |
| 102 | 101 | func (d *Daemon) clearParkingInFlightIfAbandoned(sess *session, ac *attachedClient, token uint64) { | |
| 103 | 102 | if token == 0 || ac == nil { | |
| @@ -347,9 +346,6 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool { | |||
| 347 | 346 | sess := parked.sess | |
| 348 | 347 | if sess != nil { | |
| 349 | 348 | sess.mu.Lock() | |
| 350 | - | if sess.client == ac { | |
| 351 | - | sess.client = nil | |
| 352 | - | } | |
| 353 | 349 | sess.unregisterAttachmentLocked(ac) | |
| 354 | 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 | 439 | if sess != nil { | |
| 444 | 440 | 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 | |
| 450 | 446 | if ac.clientID != h.ClientID { | |
| 451 | 447 | clientMismatch = true | |
| 452 | 448 | } else if ac.proxied != h.Proxied { | |
| @@ -455,6 +451,7 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma | |||
| 455 | 451 | oldSnap = ac.transportSnapshot() | |
| 456 | 452 | credentialMatch = oldSnap.transport != nil | |
| 457 | 453 | } | |
| 454 | + | break | |
| 458 | 455 | } | |
| 459 | 456 | sess.mu.Unlock() | |
| 460 | 457 | } | |
| @@ -37,8 +37,8 @@ func (d *Daemon) nextResumeTokenLocked() uint64 { | |||||
| 37 | // can wait out the detach→park gap. Callers must publish this before clearing | 37 | // 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-valid | 38 | // 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 still | 39 | // 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 | 40 | + | // registers the exact session and ac remains a registered attachment; otherwise |
| 41 | - | // owner; otherwise it returns 0 and never creates a marker after terminal | 41 | + | // 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 0 | 50 | 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 0 | 56 | 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 this | 97 | // clearParkingInFlightIfAbandoned drops a pre-detach parking marker when this | ||
| 99 | - | // teardown lost the seat while still the live owner (stale transport fence for | 98 | + | // teardown lost the seat while still the live owner (stale transport fence). |
| 100 | - | // active or snatched). If another path already detached/unrouted, that path | 99 | + | // 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.sess | 346 | 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.client | 441 | + | for candidate := range sess.attachments { |
| 446 | - | // resumeToken writes run under d.mu; read the live credential here | 442 | + | if !candidate.resumeCapable || candidate.resumeToken != h.ResumeToken { |
| 447 | - | // before releasing the daemon lock, then revalidate via the exact | 443 | + | 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 = true | 447 | 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 != nil | 452 | 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 | |||
| 416 | 416 | cwd := from.cwd | |
| 417 | 417 | term := from.terminal | |
| 418 | 418 | 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 { | |
| 421 | 422 | d.mu.Unlock() | |
| 422 | 423 | return errors.New("client detached") | |
| 423 | 424 | } | |
| 424 | - | from.mu.Unlock() | |
| 425 | 425 | ||
| 426 | 426 | newSess, err := d.createSessionLockedWithMode(name, false, cwd, sz, term, env, ac.proxied) | |
| 427 | 427 | d.mu.Unlock() | |
| @@ -514,11 +514,12 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||
| 514 | 514 | sess.mu.Lock() | |
| 515 | 515 | name := sess.name | |
| 516 | 516 | cwd := sess.cwd | |
| 517 | - | client := sess.client | |
| 518 | 517 | term := sess.terminal | |
| 519 | 518 | env := copyEnvironment(sess.env) | |
| 519 | + | attachments := sess.snapshotAttachmentsLocked() | |
| 520 | 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 | 523 | tabStableID, paneStableID, err := d.newTabPaneStableIDs() | |
| 523 | 524 | if err != nil { | |
| 524 | 525 | return err | |
| @@ -535,8 +536,8 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||
| 535 | 536 | return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err) | |
| 536 | 537 | } | |
| 537 | 538 | 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()) | |
| 540 | 541 | tb.mu.Lock() | |
| 541 | 542 | p := tb.focusedPane() | |
| 542 | 543 | tb.mu.Unlock() | |
| @@ -566,8 +567,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||
| 566 | 567 | // Publish ownership before appending the tab to the visible session. | |
| 567 | 568 | publishPaneOwner(p, sess, tb, 0) | |
| 568 | 569 | sess.tabs = append(sess.tabs, tb) | |
| 569 | - | sess.active = len(sess.tabs) - 1 | |
| 570 | - | tabIndex := sess.active | |
| 570 | + | tabIndex := len(sess.tabs) - 1 | |
| 571 | 571 | if !sess.ephemeral { | |
| 572 | 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 | 661 | func (s *session) fullViewportSize() domain.Size { | |
| 662 | 662 | s.mu.Lock() | |
| 663 | 663 | 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] | |
| 666 | 666 | } | |
| 667 | 667 | s.mu.Unlock() | |
| 668 | 668 | ||
| @@ -715,13 +715,12 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) { | |||
| 715 | 715 | func (s *session) detachIfCurrent(ac *attachedClient) bool { | |
| 716 | 716 | s.mu.Lock() | |
| 717 | 717 | 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 | |
| 723 | 720 | } | |
| 724 | - | return false | |
| 721 | + | s.unregisterAttachmentLocked(ac) | |
| 722 | + | ac.setSession(nil) | |
| 723 | + | return true | |
| 725 | 724 | } | |
| 726 | 725 | ||
| 727 | 726 | // detachIfCurrent publishes terminal role invalidation through the attachment | |
| @@ -744,22 +743,12 @@ func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, exp | |||
| 744 | 743 | } | |
| 745 | 744 | d.notices.routingMu.Lock() | |
| 746 | 745 | 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 | |
| 749 | 747 | if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) { | |
| 750 | 748 | current = false | |
| 751 | 749 | } | |
| 752 | 750 | if current { | |
| 753 | - | if sess.client == ac { | |
| 754 | - | sess.client = nil | |
| 755 | - | } | |
| 756 | 751 | sess.unregisterAttachmentLocked(ac) | |
| 757 | - | if sess.client == nil { | |
| 758 | - | for candidate := range sess.attachments { | |
| 759 | - | sess.client = candidate | |
| 760 | - | break | |
| 761 | - | } | |
| 762 | - | } | |
| 763 | 752 | ac.roleGeneration.Add(1) | |
| 764 | 753 | ac.setSession(nil) | |
| 765 | 754 | ac.invalidateFrozenRoleCapability() | |
| @@ -784,12 +773,11 @@ func (d *Daemon) detachProxyIfCurrentTransport(p *proxySession, ac *attachedClie | |||
| 784 | 773 | } | |
| 785 | 774 | d.notices.routingMu.Lock() | |
| 786 | 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 | 777 | if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) { | |
| 789 | 778 | current = false | |
| 790 | 779 | } | |
| 791 | 780 | if current { | |
| 792 | - | p.client = nil | |
| 793 | 781 | unregisterAttachmentSessionLocked(attachmentSession(p), ac) | |
| 794 | 782 | ac.roleGeneration.Add(1) | |
| 795 | 783 | ac.setSession(nil) | |
| @@ -852,7 +840,6 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func() | |||
| 852 | 840 | } | |
| 853 | 841 | current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req) | |
| 854 | 842 | if current { | |
| 855 | - | core.client = nil | |
| 856 | 843 | unregisterAttachmentSessionLocked(token.sess, token.ac) | |
| 857 | 844 | token.ac.roleGeneration.Add(1) | |
| 858 | 845 | token.ac.setSession(nil) | |
| @@ -888,18 +875,6 @@ func (s *session) switchTab(idx int) bool { | |||
| 888 | 875 | 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 | 878 | func (d *Daemon) renameSession(sess *session, name string) error { | |
| 904 | 879 | if name == "" { | |
| 905 | 880 | return errSessionNameRequired | |
| @@ -1068,15 +1043,8 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error { | |||
| 1068 | 1043 | return nil | |
| 1069 | 1044 | } | |
| 1070 | 1045 | ringing := tb.attention | |
| 1071 | - | wasActive := idx == sess.active | |
| 1072 | 1046 | 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() | |
| 1080 | 1048 | name := sess.name | |
| 1081 | 1049 | if !sess.ephemeral { | |
| 1082 | 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 | 1058 | tb.mu.Lock() | |
| 1091 | 1059 | panes := tb.panesSnapshot() | |
| 1092 | 1060 | tb.mu.Unlock() | |
| 1093 | - | if ac != nil { | |
| 1061 | + | for _, ac := range attachments { | |
| 1094 | 1062 | ac.pruneCaptureFrames(panes...) | |
| 1095 | 1063 | } | |
| 1096 | 1064 | if tb.cancel != nil { | |
| 1097 | 1065 | tb.cancel() | |
| 1098 | 1066 | } | |
| 1099 | - | d.teardownFloating(tb, ac) | |
| 1067 | + | for _, ac := range attachments { | |
| 1068 | + | d.teardownFloating(tb, ac) | |
| 1069 | + | } | |
| 1100 | 1070 | if rc := sess.renderCoordinator(); rc != nil { | |
| 1101 | 1071 | for _, p := range panes { | |
| 1102 | 1072 | rc.noteSyncPaneRemoved(p) | |
| 1103 | 1073 | } | |
| 1104 | 1074 | } | |
| 1105 | 1075 | 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 | + | } | |
| 1111 | 1080 | } | |
| 1112 | 1081 | if ringing { | |
| 1113 | 1082 | d.repaintAllAttachedClients() | |
| @@ -1190,9 +1159,7 @@ type sessionKillAdmission struct { | |||
| 1190 | 1159 | } | |
| 1191 | 1160 | ||
| 1192 | 1161 | type sessionKillParticipants struct { | |
| 1193 | - | active *attachedClient | |
| 1194 | 1162 | attachments map[*attachedClient]struct{} | |
| 1195 | - | snatched map[*attachedClient]struct{} | |
| 1196 | 1163 | transports map[*attachedClient]transportSnapshot | |
| 1197 | 1164 | roleGates []*attachedClient | |
| 1198 | 1165 | interrupts []roleTransportInterrupt | |
| @@ -1235,26 +1202,18 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses | |||
| 1235 | 1202 | source = target | |
| 1236 | 1203 | } | |
| 1237 | 1204 | unlockSessions := lockAttachmentSessions(source, target) | |
| 1238 | - | snapshot.active = target.client | |
| 1239 | 1205 | 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)) | |
| 1242 | 1207 | for ac := range target.attachments { | |
| 1243 | 1208 | snapshot.attachments[ac] = struct{}{} | |
| 1244 | 1209 | snapshot.transports[ac] = ac.transportSnapshot() | |
| 1245 | 1210 | } | |
| 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 | + | ||
| 1253 | 1212 | unlockSessions() | |
| 1254 | 1213 | d.notices.routingMu.Unlock() | |
| 1255 | 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 | 1217 | add := func(ac *attachedClient) { | |
| 1259 | 1218 | if ac == nil { | |
| 1260 | 1219 | return | |
| @@ -1268,13 +1227,9 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses | |||
| 1268 | 1227 | if admission != nil { | |
| 1269 | 1228 | add(admission.token.ac) | |
| 1270 | 1229 | } | |
| 1271 | - | add(snapshot.active) | |
| 1272 | 1230 | for ac := range snapshot.attachments { | |
| 1273 | 1231 | add(ac) | |
| 1274 | 1232 | } | |
| 1275 | - | for ac := range snapshot.snatched { | |
| 1276 | - | add(ac) | |
| 1277 | - | } | |
| 1278 | 1233 | for ac, transport := range snapshot.transports { | |
| 1279 | 1234 | if transport.transport != nil { | |
| 1280 | 1235 | snapshot.interrupts = append(snapshot.interrupts, roleTransportInterrupt{ac: ac, transport: transport}) | |
| @@ -1311,7 +1266,7 @@ func (d *Daemon) sessionKillParticipantsCurrent(snapshot sessionKillParticipants | |||
| 1311 | 1266 | // session and coordinator locks. | |
| 1312 | 1267 | func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sourceCoordinator *renderCoordinator) bool { | |
| 1313 | 1268 | 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 | 1270 | return false | |
| 1316 | 1271 | } | |
| 1317 | 1272 | for ac := range snapshot.attachments { | |
| @@ -1319,11 +1274,7 @@ func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sour | |||
| 1319 | 1274 | return false | |
| 1320 | 1275 | } | |
| 1321 | 1276 | } | |
| 1322 | - | for ac := range snapshot.snatched { | |
| 1323 | - | if _, current := target.snatched[ac]; !current { | |
| 1324 | - | return false | |
| 1325 | - | } | |
| 1326 | - | } | |
| 1277 | + | ||
| 1327 | 1278 | for ac, transport := range snapshot.transports { | |
| 1328 | 1279 | if !ac.transportSnapshotCurrent(transport) { | |
| 1329 | 1280 | return false | |
| @@ -1617,7 +1568,6 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1617 | 1568 | d.clearBarScriptsForSession(sess.id) | |
| 1618 | 1569 | d.purgeParkingForSessionLocked(sess) | |
| 1619 | 1570 | parkedRetirements := d.purgeParkedForSessionLocked(sess) | |
| 1620 | - | detachedActive := sess.client | |
| 1621 | 1571 | attachments := make([]detachedAttachmentSnapshot, 0, len(participants.roleGates)) | |
| 1622 | 1572 | capture := func(attached *attachedClient) { | |
| 1623 | 1573 | if attached == nil { | |
| @@ -1631,13 +1581,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1631 | 1581 | for attached := range sess.attachments { | |
| 1632 | 1582 | capture(attached) | |
| 1633 | 1583 | } | |
| 1634 | - | for snatched := range sess.snatched { | |
| 1635 | - | capture(snatched) | |
| 1636 | - | } | |
| 1637 | - | capture(sess.client) | |
| 1638 | - | sess.client = nil | |
| 1639 | 1584 | clear(sess.attachments) | |
| 1640 | - | clear(sess.snatched) | |
| 1641 | 1585 | stoppedName := sess.name | |
| 1642 | 1586 | stoppedCwd := sess.cwd | |
| 1643 | 1587 | createdAt := sess.createdAt | |
| @@ -1688,7 +1632,9 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1688 | 1632 | sess.mu.Unlock() | |
| 1689 | 1633 | for _, tb := range tabs { | |
| 1690 | 1634 | d.clearDestroyedTabPreview(tb) | |
| 1691 | - | d.teardownFloating(tb, detachedActive) | |
| 1635 | + | for _, attachment := range attachments { | |
| 1636 | + | d.teardownFloating(tb, attachment.ac) | |
| 1637 | + | } | |
| 1692 | 1638 | } | |
| 1693 | 1639 | for _, path := range clipFiles { | |
| 1694 | 1640 | if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| @@ -1801,7 +1747,7 @@ func (d *Daemon) refreshSessionCwd(sess *session) { | |||
| 1801 | 1747 | if d.procCwd == nil { | |
| 1802 | 1748 | return | |
| 1803 | 1749 | } | |
| 1804 | - | tb := sess.activeTab() | |
| 1750 | + | tb := sess.tabForAttachmentOrActive(nil) | |
| 1805 | 1751 | if tb == nil { | |
| 1806 | 1752 | return | |
| 1807 | 1753 | } | |
| @@ -416,12 +416,12 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||||
| 416 | cwd := from.cwd | 416 | cwd := from.cwd | ||
| 417 | term := from.terminal | 417 | 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.name | 515 | name := sess.name | ||
| 516 | cwd := sess.cwd | 516 | cwd := sess.cwd | ||
| 517 | - | client := sess.client | |||
| 518 | term := sess.terminal | 517 | 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 err | 525 | 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) - 1 | 570 | + | 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 *tab | 663 | 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 = nil | 719 | + | return false |
| 720 | - | s.unregisterAttachmentLocked(ac) | |||
| 721 | - | ac.setSession(nil) | |||
| 722 | - | return true | |||
| 723 | } | 720 | } | ||
| 724 | - | return false | 721 | + | s.unregisterAttachmentLocked(ac) |
| 722 | + | ac.setSession(nil) | |||
| 723 | + | return true | |||
| 725 | } | 724 | } | ||
| 726 | 725 | ||||
| 727 | // detachIfCurrent publishes terminal role invalidation through the attachment | 726 | // 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 = false | 748 | 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 = false | 778 | 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 true | 875 | 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 errSessionNameRequired | 880 | return errSessionNameRequired | ||
| @@ -1068,15 +1043,8 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error { | |||||
| 1068 | return nil | 1043 | return nil | ||
| 1069 | } | 1044 | } | ||
| 1070 | ringing := tb.attention | 1045 | 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.name | 1048 | 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]transportSnapshot | 1163 | transports map[*attachedClient]transportSnapshot | ||
| 1197 | roleGates []*attachedClient | 1164 | roleGates []*attachedClient | ||
| 1198 | interrupts []roleTransportInterrupt | 1165 | interrupts []roleTransportInterrupt | ||
| @@ -1235,26 +1202,18 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses | |||||
| 1235 | source = target | 1202 | 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 | return | 1219 | 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.target | 1268 | 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 false | 1270 | 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 false | 1274 | 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 false | 1280 | 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.name | 1585 | stoppedName := sess.name | ||
| 1642 | stoppedCwd := sess.cwd | 1586 | stoppedCwd := sess.cwd | ||
| 1643 | createdAt := sess.createdAt | 1587 | 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 | return | 1748 | return | ||
| 1803 | } | 1749 | } | ||
| 1804 | - | tb := sess.activeTab() | 1750 | + | tb := sess.tabForAttachmentOrActive(nil) |
| 1805 | if tb == nil { | 1751 | if tb == nil { | ||
| 1806 | return | 1752 | return | ||
| 1807 | } | 1753 | } | ||
M internal/usecase/daemon/session_core.go +3 -6
| @@ -55,16 +55,13 @@ func (s *session) capturePrimary(ac *attachedClient, req primaryCaptureRequest) | |||
| 55 | 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 | 60 | func (s *session) activateTargetLocked(tabIndex int) bool { | |
| 60 | 61 | if tabIndex < 0 { | |
| 61 | 62 | return true | |
| 62 | 63 | } | |
| 63 | - | if tabIndex >= len(s.tabs) { | |
| 64 | - | return false | |
| 65 | - | } | |
| 66 | - | s.active = tabIndex | |
| 67 | - | return true | |
| 64 | + | return tabIndex < len(s.tabs) | |
| 68 | 65 | } | |
| 69 | 66 | ||
| 70 | 67 | // 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 true | 62 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 6 | 7 | "github.com/bnema/vev/internal/ports" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| @@ -11,17 +12,31 @@ var errSessionMetaUnavailable = errors.New("session metadata is unavailable") | |||
| 11 | 12 | // sessionMetaSnapshot captures the authoritative tab order while session.mu | |
| 12 | 13 | // owns every field represented on the wire. | |
| 13 | 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 | 19 | if s == nil { | |
| 15 | 20 | return ports.SessionMeta{}, false | |
| 16 | 21 | } | |
| 17 | 22 | s.mu.Lock() | |
| 18 | 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 | 25 | 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 | 37 | meta := ports.SessionMeta{ | |
| 23 | 38 | SessionName: s.name, | |
| 24 | - | Active: uint16(s.active), | |
| 39 | + | Active: uint16(active), | |
| 25 | 40 | Tabs: make([]ports.SessionTabMeta, len(s.tabs)), | |
| 26 | 41 | } | |
| 27 | 42 | for i, tb := range s.tabs { | |
| @@ -67,7 +82,7 @@ func (ac *attachedClient) sendSessionMetaIfChanged(sess *session, expected trans | |||
| 67 | 82 | if ac == nil || sess == nil || !ac.proxied { | |
| 68 | 83 | return nil | |
| 69 | 84 | } | |
| 70 | - | meta, ok := sess.sessionMetaSnapshot() | |
| 85 | + | meta, ok := sess.sessionMetaSnapshotFor(ac) | |
| 71 | 86 | if !ok { | |
| 72 | 87 | return errSessionMetaUnavailable | |
| 73 | 88 | } | |
| @@ -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.mu | 12 | // 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{}, false | 20 | 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{}, false | 25 | 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 nil | 83 | return nil | ||
| 69 | } | 84 | } | ||
| 70 | - | meta, ok := sess.sessionMetaSnapshot() | 85 | + | meta, ok := sess.sessionMetaSnapshotFor(ac) |
| 71 | if !ok { | 86 | if !ok { | ||
| 72 | return errSessionMetaUnavailable | 87 | return errSessionMetaUnavailable | ||
| 73 | } | 88 | } | ||
M internal/usecase/daemon/session_view.go +13 -2
| @@ -53,15 +53,26 @@ func (s *session) snapshotView(opts viewOptions) sessionView { | |||
| 53 | 53 | opts.tabDetails = true | |
| 54 | 54 | } | |
| 55 | 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 | 67 | view := sessionView{ | |
| 57 | 68 | id: s.id, | |
| 58 | 69 | incarnation: s.incarnation, | |
| 59 | 70 | name: s.name, | |
| 60 | 71 | ephemeral: s.ephemeral, | |
| 61 | 72 | createdAt: s.createdAt, | |
| 62 | - | active: s.active, | |
| 73 | + | active: active, | |
| 63 | 74 | mruAt: s.mruAt.Load(), | |
| 64 | - | attached: len(s.attachments) != 0 || s.client != nil, | |
| 75 | + | attached: len(s.attachments) != 0, | |
| 65 | 76 | tabCount: len(s.tabs), | |
| 66 | 77 | cannotAcceptMoves: s.capabilities().cannotAcceptMoves, | |
| 67 | 78 | } | |
| @@ -53,15 +53,26 @@ func (s *session) snapshotView(opts viewOptions) sessionView { | |||||
| 53 | opts.tabDetails = true | 53 | 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 | |||
| 13 | 13 | name: sess.name, | |
| 14 | 14 | incarnation: sess.incarnation, | |
| 15 | 15 | createdAt: uint64(sess.createdAt), | |
| 16 | - | active: uint16(max(sess.active, 0)), | |
| 16 | + | active: 0, | |
| 17 | 17 | } | |
| 18 | 18 | ephemeral := sess.ephemeral | |
| 19 | 19 | 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.ephemeral | 18 | ephemeral := sess.ephemeral | ||
| 19 | fallbackCwd := sess.cwd | 19 | 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 | |||
| 290 | 290 | } | |
| 291 | 291 | ||
| 292 | 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 | 294 | // Restored tabs remain private until persistAndRegisterRestoredSession. | |
| 295 | 295 | // Initialize owners now so registration and reader startup cannot expose an | |
| 296 | 296 | // 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 an | 295 | // 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 ( | |||
| 6 | 6 | "strconv" | |
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | 10 | "github.com/bnema/vev/internal/usecase/command" | |
| 10 | 11 | "github.com/bnema/vev/internal/usecase/palette" | |
| 11 | 12 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| @@ -181,6 +182,10 @@ type statusTab struct { | |||
| 181 | 182 | } | |
| 182 | 183 | ||
| 183 | 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 | 189 | s.mu.Lock() | |
| 185 | 190 | defer s.mu.Unlock() | |
| 186 | 191 | name := s.name | |
| @@ -188,9 +193,19 @@ func (s *session) statusSegments(includeTerminalTitle bool) statusSnapshot { | |||
| 188 | 193 | name += "*" | |
| 189 | 194 | } | |
| 190 | 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 | 206 | for i, tb := range s.tabs { | |
| 192 | 207 | name := tabDisplayName(tb, i) | |
| 193 | - | active := i == s.active | |
| 208 | + | active := i == activeIndex | |
| 194 | 209 | attention := tb.attention && (!active || tb.attentionVisiblePaint) | |
| 195 | 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 | 251 | // attachment implementation. Bar scripts remain local-session-only because | |
| 237 | 252 | // their existing execution contract depends on local tabs and PTYs. | |
| 238 | 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 | 258 | if attachmentSessionCore(cur) == nil { | |
| 240 | 259 | cur = nil | |
| 241 | 260 | } | |
| @@ -249,7 +268,11 @@ func (d *Daemon) barStateForAttachmentPaletteHints(cur attachmentSession, status | |||
| 249 | 268 | if d != nil { | |
| 250 | 269 | 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 | 277 | if local, ok := localSession(cur); ok && d != nil { | |
| 255 | 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 | 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 | 299 | state := barState{statusFeedback: statusFeedback} | |
| 273 | 300 | if d != nil { | |
| 274 | 301 | state.attentionFrame = d.attentionFrame() | |
| @@ -278,7 +305,7 @@ func (d *Daemon) barStateBase(cur *session, statusFeedback string) barState { | |||
| 278 | 305 | if d != nil { | |
| 279 | 306 | includeTerminalTitle = d.currentTabsConfig().TerminalTitle | |
| 280 | 307 | } | |
| 281 | - | state.status = cur.statusSegments(includeTerminalTitle) | |
| 308 | + | state.status = cur.statusSegmentsFor(ac, includeTerminalTitle) | |
| 282 | 309 | } | |
| 283 | 310 | if d != nil { | |
| 284 | 311 | 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.name | 191 | 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.active | 208 | + | 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 because | 251 | // 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 = nil | 259 | 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().TerminalTitle | 269 | 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().TerminalTitle | 306 | 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 | |||
| 12 | 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 | 15 | func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool { | |
| 24 | 16 | // Resolve while applying, never while composing. The theme mutex only | |
| 25 | 17 | // 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 | 20 | t = applied.Raw | |
| 29 | 21 | sess.mu.Lock() | |
| 30 | 22 | if ac != nil { | |
| 31 | - | if sess.client != ac { | |
| 23 | + | if _, attached := sess.attachments[ac]; !attached { | |
| 32 | 24 | sess.mu.Unlock() | |
| 33 | 25 | return false | |
| 34 | 26 | } | |
| 35 | - | } else if sess.client != nil { | |
| 36 | - | sess.mu.Unlock() | |
| 37 | - | return false | |
| 38 | 27 | } | |
| 39 | 28 | tabs := append([]*tab(nil), sess.tabs...) | |
| 40 | 29 | 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 only | 16 | // Resolve while applying, never while composing. The theme mutex only | ||
| 25 | // publishes the completed value and is not held across session/tab/pane | 17 | // 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.Raw | 20 | 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 false | 25 | 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) { | |||
| 362 | 362 | return | |
| 363 | 363 | } | |
| 364 | 364 | markSnapshotDirty(sess) | |
| 365 | - | sess.mu.Lock() | |
| 366 | - | ac := sess.client | |
| 367 | - | sess.mu.Unlock() | |
| 368 | - | if ac != nil { | |
| 365 | + | for _, ac := range sess.snapshotAttachments() { | |
| 369 | 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 | 556 | sess.mu.Lock() | |
| 560 | 557 | tabs := append([]*tab(nil), sess.tabs...) | |
| 561 | - | active := sess.active | |
| 562 | 558 | sess.mu.Unlock() | |
| 563 | 559 | plans := make([]*preparedTabLayout, 0, len(tabs)) | |
| 564 | 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) { | |
| 637 | - | floatingFailed, ok := d.applyVisibleFloatingLayout(sess, tabs[active], current) | |
| 632 | + | for _, tb := range tabs { | |
| 633 | + | floatingFailed, ok := d.applyVisibleFloatingLayout(sess, tb, current) | |
| 638 | 634 | if !ok { | |
| 639 | 635 | return nil, false | |
| 640 | 636 | } | |
| @@ -362,10 +362,7 @@ func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) { | |||||
| 362 | return | 362 | 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, false | 635 | return nil, false | ||
| 640 | } | 636 | } | ||