feat(daemon): add multi-attachment session views
- commit
- d3184489b89c7cdfa16543f2252759b547205f2b
- author
- brice · · 2026-08-02 22:39 UTC
- committer
- brice · · 2026-08-03 20:53 UTC
- parent
- 738b9eb
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
19 files · +678 -46
A internal/usecase/daemon/attachment_registry.go +439 -0
| @@ -0,0 +1,439 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "cmp" | |
| 6 | + | "slices" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/pkg/vt" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // attachmentView is the mutable viewport owned by one attachment. Tab and | |
| 13 | + | // pane identity is stable across tab insertion, deletion, and reordering; | |
| 14 | + | // neither field is a session-local index or layout PaneID. | |
| 15 | + | type attachmentView struct { | |
| 16 | + | tabID domain.TabStableID | |
| 17 | + | paneID domain.PaneStableID | |
| 18 | + | windowTop int | |
| 19 | + | windowRows int | |
| 20 | + | bookmark vt.RowID | |
| 21 | + | liveBottom bool | |
| 22 | + | revision uint64 | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // attachmentViewSnapshot is the immutable value returned to callers that need | |
| 26 | + | // to inspect attachment-local state without retaining its mutex. | |
| 27 | + | type attachmentViewSnapshot struct { | |
| 28 | + | attachment *attachedClient | |
| 29 | + | view attachmentView | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // viewInvalidation records the order in which attachment views observed one | |
| 33 | + | // shared session mutation. The order is stable client-ID order, not map | |
| 34 | + | // iteration or lock-acquisition order, which keeps concurrent attaches | |
| 35 | + | // deterministic. | |
| 36 | + | type viewInvalidation struct { | |
| 37 | + | attachment *attachedClient | |
| 38 | + | revision uint64 | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func (ac *attachedClient) viewSnapshot() attachmentView { | |
| 42 | + | if ac == nil { | |
| 43 | + | return attachmentView{} | |
| 44 | + | } | |
| 45 | + | ac.viewMu.Lock() | |
| 46 | + | defer ac.viewMu.Unlock() | |
| 47 | + | return ac.view | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func (ac *attachedClient) publishView(view attachmentView) { | |
| 51 | + | if ac == nil { | |
| 52 | + | return | |
| 53 | + | } | |
| 54 | + | ac.viewMu.Lock() | |
| 55 | + | ac.view = view | |
| 56 | + | ac.viewMu.Unlock() | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // registerAttachmentLocked admits ac exactly once. Caller holds s.mu and must | |
| 60 | + | // have acquired dispatchMu first when the operation came from a client. | |
| 61 | + | func (s *session) registerAttachmentLocked(ac *attachedClient) bool { | |
| 62 | + | if s == nil || !s.sessionCore.registerAttachmentLocked(ac) { | |
| 63 | + | return false | |
| 64 | + | } | |
| 65 | + | view := ac.viewSnapshot() | |
| 66 | + | if view.tabID == "" || view.paneID == "" { | |
| 67 | + | view = s.repairAttachmentViewLocked(ac, view) | |
| 68 | + | ac.publishView(view) | |
| 69 | + | } | |
| 70 | + | return true | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func (c *sessionCore) registerAttachmentLocked(ac *attachedClient) bool { | |
| 74 | + | if c == nil || ac == nil { | |
| 75 | + | return false | |
| 76 | + | } | |
| 77 | + | if c.attachments == nil { | |
| 78 | + | c.attachments = make(map[*attachedClient]struct{}) | |
| 79 | + | } | |
| 80 | + | if _, exists := c.attachments[ac]; exists { | |
| 81 | + | return false | |
| 82 | + | } | |
| 83 | + | if c.attachmentOrder == nil { | |
| 84 | + | c.attachmentOrder = make(map[*attachedClient]uint64) | |
| 85 | + | } | |
| 86 | + | c.nextAttachmentID++ | |
| 87 | + | c.attachments[ac] = struct{}{} | |
| 88 | + | c.attachmentOrder[ac] = c.nextAttachmentID | |
| 89 | + | return true | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func registerAttachmentSessionLocked(entry attachmentSession, ac *attachedClient) bool { | |
| 93 | + | if entry == nil || entry.core() == nil { | |
| 94 | + | return false | |
| 95 | + | } | |
| 96 | + | // Transition publication may already hold tab locks (move commit). Only | |
| 97 | + | // publish collection membership here; target repair runs at the next view | |
| 98 | + | // snapshot outside that critical section. | |
| 99 | + | return entry.core().registerAttachmentLocked(ac) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func (s *session) registerAttachment(ac *attachedClient) bool { | |
| 103 | + | if s == nil || ac == nil { | |
| 104 | + | return false | |
| 105 | + | } | |
| 106 | + | s.dispatchMu.Lock() | |
| 107 | + | defer s.dispatchMu.Unlock() | |
| 108 | + | s.mu.Lock() | |
| 109 | + | defer s.mu.Unlock() | |
| 110 | + | return s.registerAttachmentLocked(ac) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // unregisterAttachmentLocked removes only ac and leaves every other | |
| 114 | + | // attachment's view intact. Caller holds s.mu. | |
| 115 | + | func (s *session) unregisterAttachmentLocked(ac *attachedClient) bool { | |
| 116 | + | if s == nil { | |
| 117 | + | return false | |
| 118 | + | } | |
| 119 | + | return s.sessionCore.unregisterAttachmentLocked(ac) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func (c *sessionCore) unregisterAttachmentLocked(ac *attachedClient) bool { | |
| 123 | + | if c == nil || ac == nil || c.attachments == nil { | |
| 124 | + | return false | |
| 125 | + | } | |
| 126 | + | if _, exists := c.attachments[ac]; !exists { | |
| 127 | + | return false | |
| 128 | + | } | |
| 129 | + | delete(c.attachments, ac) | |
| 130 | + | delete(c.attachmentOrder, ac) | |
| 131 | + | delete(c.snatched, ac) | |
| 132 | + | if c.client == ac { | |
| 133 | + | c.client = nil | |
| 134 | + | } | |
| 135 | + | return true | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func unregisterAttachmentSessionLocked(entry attachmentSession, ac *attachedClient) bool { | |
| 139 | + | if entry == nil || entry.core() == nil { | |
| 140 | + | return false | |
| 141 | + | } | |
| 142 | + | return entry.core().unregisterAttachmentLocked(ac) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func (s *session) unregisterAttachment(ac *attachedClient) bool { | |
| 146 | + | if s == nil || ac == nil { | |
| 147 | + | return false | |
| 148 | + | } | |
| 149 | + | s.dispatchMu.Lock() | |
| 150 | + | defer s.dispatchMu.Unlock() | |
| 151 | + | s.mu.Lock() | |
| 152 | + | defer s.mu.Unlock() | |
| 153 | + | return s.unregisterAttachmentLocked(ac) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // snapshotAttachmentsLocked returns a stable registration-ordered snapshot. | |
| 157 | + | // Callers hold s.mu; returned attachments may be used after unlocking. | |
| 158 | + | func (s *session) snapshotAttachmentsLocked() []*attachedClient { | |
| 159 | + | if s == nil || len(s.attachments) == 0 { | |
| 160 | + | return nil | |
| 161 | + | } | |
| 162 | + | out := make([]*attachedClient, 0, len(s.attachments)) | |
| 163 | + | for ac := range s.attachments { | |
| 164 | + | out = append(out, ac) | |
| 165 | + | } | |
| 166 | + | slices.SortStableFunc(out, func(a, b *attachedClient) int { | |
| 167 | + | // ClientID is the stable wire identity, so concurrent registration order | |
| 168 | + | // cannot affect snapshots or invalidation order. | |
| 169 | + | if id := bytes.Compare(a.clientID[:], b.clientID[:]); id != 0 { | |
| 170 | + | return id | |
| 171 | + | } | |
| 172 | + | return cmp.Compare(s.attachmentOrder[a], s.attachmentOrder[b]) | |
| 173 | + | }) | |
| 174 | + | return out | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func (s *session) snapshotAttachments() []*attachedClient { | |
| 178 | + | if s == nil { | |
| 179 | + | return nil | |
| 180 | + | } | |
| 181 | + | s.mu.Lock() | |
| 182 | + | defer s.mu.Unlock() | |
| 183 | + | return s.snapshotAttachmentsLocked() | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func (s *session) snapshotAttachmentViews() []attachmentViewSnapshot { | |
| 187 | + | attachments := s.snapshotAttachments() | |
| 188 | + | if len(attachments) == 0 { | |
| 189 | + | return nil | |
| 190 | + | } | |
| 191 | + | views := make([]attachmentViewSnapshot, 0, len(attachments)) | |
| 192 | + | for _, ac := range attachments { | |
| 193 | + | views = append(views, attachmentViewSnapshot{attachment: ac, view: ac.viewSnapshot()}) | |
| 194 | + | } | |
| 195 | + | return views | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // repairAttachmentViewLocked validates stable targets and chooses the nearest | |
| 199 | + | // surviving target when a tab or pane was removed. Caller holds s.mu. | |
| 200 | + | func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachmentView) attachmentView { | |
| 201 | + | _ = ac | |
| 202 | + | if s == nil { | |
| 203 | + | return attachmentView{} | |
| 204 | + | } | |
| 205 | + | var targetTab *tab | |
| 206 | + | for _, tb := range s.tabs { | |
| 207 | + | if tb != nil && domain.TabStableID(tb.stableID) == view.tabID { | |
| 208 | + | targetTab = tb | |
| 209 | + | break | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | tabRepaired := targetTab == nil | |
| 213 | + | if targetTab == nil && len(s.tabs) != 0 { | |
| 214 | + | targetTab = s.tabs[0] | |
| 215 | + | } | |
| 216 | + | if targetTab == nil { | |
| 217 | + | view.tabID = "" | |
| 218 | + | view.paneID = "" | |
| 219 | + | view.windowTop = 0 | |
| 220 | + | view.windowRows = 0 | |
| 221 | + | view.bookmark = 0 | |
| 222 | + | view.liveBottom = true | |
| 223 | + | return view | |
| 224 | + | } | |
| 225 | + | view.tabID = domain.TabStableID(targetTab.stableID) | |
| 226 | + | ||
| 227 | + | targetPane := (*pane)(nil) | |
| 228 | + | targetTab.mu.Lock() | |
| 229 | + | for _, candidate := range targetTab.panes { | |
| 230 | + | if candidate != nil && domain.PaneStableID(candidate.stableID) == view.paneID { | |
| 231 | + | targetPane = candidate | |
| 232 | + | break | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | paneRepaired := targetPane == nil | |
| 236 | + | if targetPane == nil { | |
| 237 | + | targetPane = targetTab.focusedPane() | |
| 238 | + | } | |
| 239 | + | if targetPane != nil { | |
| 240 | + | view.paneID = domain.PaneStableID(targetPane.stableID) | |
| 241 | + | } else { | |
| 242 | + | view.paneID = "" | |
| 243 | + | } | |
| 244 | + | targetTab.mu.Unlock() | |
| 245 | + | if view.windowRows <= 0 { | |
| 246 | + | targetTab.mu.Lock() | |
| 247 | + | view.windowRows = targetTab.size.Rows | |
| 248 | + | targetTab.mu.Unlock() | |
| 249 | + | } | |
| 250 | + | if view.windowTop < 0 { | |
| 251 | + | view.windowTop = 0 | |
| 252 | + | } | |
| 253 | + | if tabRepaired || paneRepaired || targetPane == nil { | |
| 254 | + | view.bookmark = 0 | |
| 255 | + | view.liveBottom = true | |
| 256 | + | } | |
| 257 | + | return view | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | // repairAttachmentView repairs ac and increments its revision only when the | |
| 261 | + | // stable target changed. It is deliberately separate from registration so | |
| 262 | + | // layout mutation code can validate every surviving attachment in one ordered | |
| 263 | + | // pass. | |
| 264 | + | func (s *session) repairAttachmentView(ac *attachedClient) bool { | |
| 265 | + | if s == nil || ac == nil { | |
| 266 | + | return false | |
| 267 | + | } | |
| 268 | + | s.mu.Lock() | |
| 269 | + | defer s.mu.Unlock() | |
| 270 | + | if _, ok := s.attachments[ac]; !ok { | |
| 271 | + | return false | |
| 272 | + | } | |
| 273 | + | before := ac.viewSnapshot() | |
| 274 | + | after := s.repairAttachmentViewLocked(ac, before) | |
| 275 | + | if before.tabID == after.tabID && before.paneID == after.paneID && before.windowTop == after.windowTop && before.windowRows == after.windowRows && before.bookmark == after.bookmark && before.liveBottom == after.liveBottom { | |
| 276 | + | return false | |
| 277 | + | } | |
| 278 | + | after.revision++ | |
| 279 | + | ac.publishView(after) | |
| 280 | + | return true | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | // invalidateViewsLocked orders all per-attachment invalidations behind the | |
| 284 | + | // shared session mutation. Caller holds s.mu and dispatchMu. | |
| 285 | + | func (s *session) invalidateViewsLocked() []viewInvalidation { | |
| 286 | + | attachments := s.snapshotAttachmentsLocked() | |
| 287 | + | invalidations := make([]viewInvalidation, 0, len(attachments)) | |
| 288 | + | for _, ac := range attachments { | |
| 289 | + | view := ac.viewSnapshot() | |
| 290 | + | repaired := s.repairAttachmentViewLocked(ac, view) | |
| 291 | + | repaired.revision++ | |
| 292 | + | ac.publishView(repaired) | |
| 293 | + | invalidations = append(invalidations, viewInvalidation{attachment: ac, revision: repaired.revision}) | |
| 294 | + | } | |
| 295 | + | return invalidations | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | // runMutation is the one admission boundary for shared session mutations. | |
| 299 | + | // Callers perform their state mutation and view invalidation inside fn; the | |
| 300 | + | // dispatch lock is acquired before any session, tab, or daemon lock. | |
| 301 | + | func (s *session) runMutation(fn func() error) error { | |
| 302 | + | if s == nil || fn == nil { | |
| 303 | + | return nil | |
| 304 | + | } | |
| 305 | + | s.dispatchMu.Lock() | |
| 306 | + | defer s.dispatchMu.Unlock() | |
| 307 | + | return fn() | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | // tabForAttachment resolves the attachment's stable target after validating | |
| 311 | + | // it against current membership. It never changes another attachment's view. | |
| 312 | + | func (s *session) tabForAttachment(ac *attachedClient) *tab { | |
| 313 | + | if s == nil || ac == nil { | |
| 314 | + | return nil | |
| 315 | + | } | |
| 316 | + | s.repairAttachmentView(ac) | |
| 317 | + | s.mu.Lock() | |
| 318 | + | defer s.mu.Unlock() | |
| 319 | + | if _, ok := s.attachments[ac]; !ok { | |
| 320 | + | return nil | |
| 321 | + | } | |
| 322 | + | view := ac.viewSnapshot() | |
| 323 | + | repaired := s.repairAttachmentViewLocked(ac, view) | |
| 324 | + | if repaired.tabID != view.tabID || repaired.paneID != view.paneID || repaired.windowTop != view.windowTop || repaired.windowRows != view.windowRows || repaired.bookmark != view.bookmark || repaired.liveBottom != view.liveBottom { | |
| 325 | + | repaired.revision++ | |
| 326 | + | ac.publishView(repaired) | |
| 327 | + | } | |
| 328 | + | for _, tb := range s.tabs { | |
| 329 | + | if tb != nil && domain.TabStableID(tb.stableID) == repaired.tabID { | |
| 330 | + | return tb | |
| 331 | + | } | |
| 332 | + | } | |
| 333 | + | return nil | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | // paneForAttachment resolves both stable IDs and repairs a removed pane before | |
| 337 | + | // returning it. The tab remains shared; only the attachment's view moves. | |
| 338 | + | func (s *session) tabForAttachmentOrActive(ac *attachedClient) *tab { | |
| 339 | + | if tb := s.tabForAttachment(ac); tb != nil { | |
| 340 | + | return tb | |
| 341 | + | } | |
| 342 | + | return s.activeTab() | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | func (s *session) paneForAttachment(ac *attachedClient) (*tab, *pane) { | |
| 346 | + | tb := s.tabForAttachment(ac) | |
| 347 | + | if tb == nil { | |
| 348 | + | return nil, nil | |
| 349 | + | } | |
| 350 | + | view := ac.viewSnapshot() | |
| 351 | + | tb.mu.Lock() | |
| 352 | + | defer tb.mu.Unlock() | |
| 353 | + | for _, p := range tb.panes { | |
| 354 | + | if p != nil && domain.PaneStableID(p.stableID) == view.paneID { | |
| 355 | + | return tb, p | |
| 356 | + | } | |
| 357 | + | } | |
| 358 | + | return tb, tb.focusedPane() | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | // updateAttachmentView applies one attachment-local view mutation at the | |
| 362 | + | // shared mutation boundary. The revision increments exactly once and target | |
| 363 | + | // IDs are repaired before publication. | |
| 364 | + | func (s *session) updateAttachmentView(ac *attachedClient, update func(*attachmentView)) bool { | |
| 365 | + | if s == nil || ac == nil || update == nil { | |
| 366 | + | return false | |
| 367 | + | } | |
| 368 | + | changed := false | |
| 369 | + | _ = s.runMutation(func() error { | |
| 370 | + | s.mu.Lock() | |
| 371 | + | defer s.mu.Unlock() | |
| 372 | + | if _, ok := s.attachments[ac]; !ok { | |
| 373 | + | return nil | |
| 374 | + | } | |
| 375 | + | before := ac.viewSnapshot() | |
| 376 | + | next := before | |
| 377 | + | update(&next) | |
| 378 | + | next = s.repairAttachmentViewLocked(ac, next) | |
| 379 | + | next.revision++ | |
| 380 | + | ac.publishView(next) | |
| 381 | + | changed = next.tabID != before.tabID || next.paneID != before.paneID || next.windowTop != before.windowTop || next.windowRows != before.windowRows || next.bookmark != before.bookmark || next.liveBottom != before.liveBottom | |
| 382 | + | return nil | |
| 383 | + | }) | |
| 384 | + | return changed | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | // selectAttachmentTab changes only one attachment's target. Shared tab order | |
| 388 | + | // and PTY ownership are untouched. | |
| 389 | + | func (s *session) selectAttachmentTab(ac *attachedClient, tabID domain.TabStableID) bool { | |
| 390 | + | if s == nil || ac == nil { | |
| 391 | + | return false | |
| 392 | + | } | |
| 393 | + | var changed bool | |
| 394 | + | _ = s.runMutation(func() error { | |
| 395 | + | s.mu.Lock() | |
| 396 | + | defer s.mu.Unlock() | |
| 397 | + | if _, ok := s.attachments[ac]; !ok { | |
| 398 | + | return nil | |
| 399 | + | } | |
| 400 | + | view := ac.viewSnapshot() | |
| 401 | + | view.tabID = tabID | |
| 402 | + | view.paneID = "" | |
| 403 | + | view = s.repairAttachmentViewLocked(ac, view) | |
| 404 | + | view.revision++ | |
| 405 | + | ac.publishView(view) | |
| 406 | + | changed = view.tabID == tabID | |
| 407 | + | s.invalidateViewsLocked() | |
| 408 | + | return nil | |
| 409 | + | }) | |
| 410 | + | return changed | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | func (s *session) switchAttachmentTab(ac *attachedClient, idx int) bool { | |
| 414 | + | if s == nil || ac == nil { | |
| 415 | + | return false | |
| 416 | + | } | |
| 417 | + | s.mu.Lock() | |
| 418 | + | registered := false | |
| 419 | + | if s.attachments != nil { | |
| 420 | + | _, registered = s.attachments[ac] | |
| 421 | + | } | |
| 422 | + | if registered && idx >= 0 && idx < len(s.tabs) { | |
| 423 | + | tabID := domain.TabStableID(s.tabs[idx].stableID) | |
| 424 | + | s.mu.Unlock() | |
| 425 | + | return s.selectAttachmentTab(ac, tabID) | |
| 426 | + | } | |
| 427 | + | s.mu.Unlock() | |
| 428 | + | return s.switchTab(idx) | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | // repairAttachmentViewsLocked repairs all live attachment targets after a tab | |
| 432 | + | // or pane mutation. It is intended to run at the same mutation linearization | |
| 433 | + | // point as the shared membership change. | |
| 434 | + | func (s *session) repairAttachmentViewsLocked() []viewInvalidation { | |
| 435 | + | if s == nil { | |
| 436 | + | return nil | |
| 437 | + | } | |
| 438 | + | return s.invalidateViewsLocked() | |
| 439 | + | } | |
| @@ -0,0 +1,439 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "cmp" | |||
| 6 | + | "slices" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | "github.com/bnema/vev/pkg/vt" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // attachmentView is the mutable viewport owned by one attachment. Tab and | |||
| 13 | + | // pane identity is stable across tab insertion, deletion, and reordering; | |||
| 14 | + | // neither field is a session-local index or layout PaneID. | |||
| 15 | + | type attachmentView struct { | |||
| 16 | + | tabID domain.TabStableID | |||
| 17 | + | paneID domain.PaneStableID | |||
| 18 | + | windowTop int | |||
| 19 | + | windowRows int | |||
| 20 | + | bookmark vt.RowID | |||
| 21 | + | liveBottom bool | |||
| 22 | + | revision uint64 | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // attachmentViewSnapshot is the immutable value returned to callers that need | |||
| 26 | + | // to inspect attachment-local state without retaining its mutex. | |||
| 27 | + | type attachmentViewSnapshot struct { | |||
| 28 | + | attachment *attachedClient | |||
| 29 | + | view attachmentView | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // viewInvalidation records the order in which attachment views observed one | |||
| 33 | + | // shared session mutation. The order is stable client-ID order, not map | |||
| 34 | + | // iteration or lock-acquisition order, which keeps concurrent attaches | |||
| 35 | + | // deterministic. | |||
| 36 | + | type viewInvalidation struct { | |||
| 37 | + | attachment *attachedClient | |||
| 38 | + | revision uint64 | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (ac *attachedClient) viewSnapshot() attachmentView { | |||
| 42 | + | if ac == nil { | |||
| 43 | + | return attachmentView{} | |||
| 44 | + | } | |||
| 45 | + | ac.viewMu.Lock() | |||
| 46 | + | defer ac.viewMu.Unlock() | |||
| 47 | + | return ac.view | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func (ac *attachedClient) publishView(view attachmentView) { | |||
| 51 | + | if ac == nil { | |||
| 52 | + | return | |||
| 53 | + | } | |||
| 54 | + | ac.viewMu.Lock() | |||
| 55 | + | ac.view = view | |||
| 56 | + | ac.viewMu.Unlock() | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // registerAttachmentLocked admits ac exactly once. Caller holds s.mu and must | |||
| 60 | + | // have acquired dispatchMu first when the operation came from a client. | |||
| 61 | + | func (s *session) registerAttachmentLocked(ac *attachedClient) bool { | |||
| 62 | + | if s == nil || !s.sessionCore.registerAttachmentLocked(ac) { | |||
| 63 | + | return false | |||
| 64 | + | } | |||
| 65 | + | view := ac.viewSnapshot() | |||
| 66 | + | if view.tabID == "" || view.paneID == "" { | |||
| 67 | + | view = s.repairAttachmentViewLocked(ac, view) | |||
| 68 | + | ac.publishView(view) | |||
| 69 | + | } | |||
| 70 | + | return true | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func (c *sessionCore) registerAttachmentLocked(ac *attachedClient) bool { | |||
| 74 | + | if c == nil || ac == nil { | |||
| 75 | + | return false | |||
| 76 | + | } | |||
| 77 | + | if c.attachments == nil { | |||
| 78 | + | c.attachments = make(map[*attachedClient]struct{}) | |||
| 79 | + | } | |||
| 80 | + | if _, exists := c.attachments[ac]; exists { | |||
| 81 | + | return false | |||
| 82 | + | } | |||
| 83 | + | if c.attachmentOrder == nil { | |||
| 84 | + | c.attachmentOrder = make(map[*attachedClient]uint64) | |||
| 85 | + | } | |||
| 86 | + | c.nextAttachmentID++ | |||
| 87 | + | c.attachments[ac] = struct{}{} | |||
| 88 | + | c.attachmentOrder[ac] = c.nextAttachmentID | |||
| 89 | + | return true | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func registerAttachmentSessionLocked(entry attachmentSession, ac *attachedClient) bool { | |||
| 93 | + | if entry == nil || entry.core() == nil { | |||
| 94 | + | return false | |||
| 95 | + | } | |||
| 96 | + | // Transition publication may already hold tab locks (move commit). Only | |||
| 97 | + | // publish collection membership here; target repair runs at the next view | |||
| 98 | + | // snapshot outside that critical section. | |||
| 99 | + | return entry.core().registerAttachmentLocked(ac) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func (s *session) registerAttachment(ac *attachedClient) bool { | |||
| 103 | + | if s == nil || ac == nil { | |||
| 104 | + | return false | |||
| 105 | + | } | |||
| 106 | + | s.dispatchMu.Lock() | |||
| 107 | + | defer s.dispatchMu.Unlock() | |||
| 108 | + | s.mu.Lock() | |||
| 109 | + | defer s.mu.Unlock() | |||
| 110 | + | return s.registerAttachmentLocked(ac) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | // unregisterAttachmentLocked removes only ac and leaves every other | |||
| 114 | + | // attachment's view intact. Caller holds s.mu. | |||
| 115 | + | func (s *session) unregisterAttachmentLocked(ac *attachedClient) bool { | |||
| 116 | + | if s == nil { | |||
| 117 | + | return false | |||
| 118 | + | } | |||
| 119 | + | return s.sessionCore.unregisterAttachmentLocked(ac) | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func (c *sessionCore) unregisterAttachmentLocked(ac *attachedClient) bool { | |||
| 123 | + | if c == nil || ac == nil || c.attachments == nil { | |||
| 124 | + | return false | |||
| 125 | + | } | |||
| 126 | + | if _, exists := c.attachments[ac]; !exists { | |||
| 127 | + | return false | |||
| 128 | + | } | |||
| 129 | + | delete(c.attachments, ac) | |||
| 130 | + | delete(c.attachmentOrder, ac) | |||
| 131 | + | delete(c.snatched, ac) | |||
| 132 | + | if c.client == ac { | |||
| 133 | + | c.client = nil | |||
| 134 | + | } | |||
| 135 | + | return true | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func unregisterAttachmentSessionLocked(entry attachmentSession, ac *attachedClient) bool { | |||
| 139 | + | if entry == nil || entry.core() == nil { | |||
| 140 | + | return false | |||
| 141 | + | } | |||
| 142 | + | return entry.core().unregisterAttachmentLocked(ac) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func (s *session) unregisterAttachment(ac *attachedClient) bool { | |||
| 146 | + | if s == nil || ac == nil { | |||
| 147 | + | return false | |||
| 148 | + | } | |||
| 149 | + | s.dispatchMu.Lock() | |||
| 150 | + | defer s.dispatchMu.Unlock() | |||
| 151 | + | s.mu.Lock() | |||
| 152 | + | defer s.mu.Unlock() | |||
| 153 | + | return s.unregisterAttachmentLocked(ac) | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // snapshotAttachmentsLocked returns a stable registration-ordered snapshot. | |||
| 157 | + | // Callers hold s.mu; returned attachments may be used after unlocking. | |||
| 158 | + | func (s *session) snapshotAttachmentsLocked() []*attachedClient { | |||
| 159 | + | if s == nil || len(s.attachments) == 0 { | |||
| 160 | + | return nil | |||
| 161 | + | } | |||
| 162 | + | out := make([]*attachedClient, 0, len(s.attachments)) | |||
| 163 | + | for ac := range s.attachments { | |||
| 164 | + | out = append(out, ac) | |||
| 165 | + | } | |||
| 166 | + | slices.SortStableFunc(out, func(a, b *attachedClient) int { | |||
| 167 | + | // ClientID is the stable wire identity, so concurrent registration order | |||
| 168 | + | // cannot affect snapshots or invalidation order. | |||
| 169 | + | if id := bytes.Compare(a.clientID[:], b.clientID[:]); id != 0 { | |||
| 170 | + | return id | |||
| 171 | + | } | |||
| 172 | + | return cmp.Compare(s.attachmentOrder[a], s.attachmentOrder[b]) | |||
| 173 | + | }) | |||
| 174 | + | return out | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | func (s *session) snapshotAttachments() []*attachedClient { | |||
| 178 | + | if s == nil { | |||
| 179 | + | return nil | |||
| 180 | + | } | |||
| 181 | + | s.mu.Lock() | |||
| 182 | + | defer s.mu.Unlock() | |||
| 183 | + | return s.snapshotAttachmentsLocked() | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | func (s *session) snapshotAttachmentViews() []attachmentViewSnapshot { | |||
| 187 | + | attachments := s.snapshotAttachments() | |||
| 188 | + | if len(attachments) == 0 { | |||
| 189 | + | return nil | |||
| 190 | + | } | |||
| 191 | + | views := make([]attachmentViewSnapshot, 0, len(attachments)) | |||
| 192 | + | for _, ac := range attachments { | |||
| 193 | + | views = append(views, attachmentViewSnapshot{attachment: ac, view: ac.viewSnapshot()}) | |||
| 194 | + | } | |||
| 195 | + | return views | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | // repairAttachmentViewLocked validates stable targets and chooses the nearest | |||
| 199 | + | // surviving target when a tab or pane was removed. Caller holds s.mu. | |||
| 200 | + | func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachmentView) attachmentView { | |||
| 201 | + | _ = ac | |||
| 202 | + | if s == nil { | |||
| 203 | + | return attachmentView{} | |||
| 204 | + | } | |||
| 205 | + | var targetTab *tab | |||
| 206 | + | for _, tb := range s.tabs { | |||
| 207 | + | if tb != nil && domain.TabStableID(tb.stableID) == view.tabID { | |||
| 208 | + | targetTab = tb | |||
| 209 | + | break | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | tabRepaired := targetTab == nil | |||
| 213 | + | if targetTab == nil && len(s.tabs) != 0 { | |||
| 214 | + | targetTab = s.tabs[0] | |||
| 215 | + | } | |||
| 216 | + | if targetTab == nil { | |||
| 217 | + | view.tabID = "" | |||
| 218 | + | view.paneID = "" | |||
| 219 | + | view.windowTop = 0 | |||
| 220 | + | view.windowRows = 0 | |||
| 221 | + | view.bookmark = 0 | |||
| 222 | + | view.liveBottom = true | |||
| 223 | + | return view | |||
| 224 | + | } | |||
| 225 | + | view.tabID = domain.TabStableID(targetTab.stableID) | |||
| 226 | + | ||||
| 227 | + | targetPane := (*pane)(nil) | |||
| 228 | + | targetTab.mu.Lock() | |||
| 229 | + | for _, candidate := range targetTab.panes { | |||
| 230 | + | if candidate != nil && domain.PaneStableID(candidate.stableID) == view.paneID { | |||
| 231 | + | targetPane = candidate | |||
| 232 | + | break | |||
| 233 | + | } | |||
| 234 | + | } | |||
| 235 | + | paneRepaired := targetPane == nil | |||
| 236 | + | if targetPane == nil { | |||
| 237 | + | targetPane = targetTab.focusedPane() | |||
| 238 | + | } | |||
| 239 | + | if targetPane != nil { | |||
| 240 | + | view.paneID = domain.PaneStableID(targetPane.stableID) | |||
| 241 | + | } else { | |||
| 242 | + | view.paneID = "" | |||
| 243 | + | } | |||
| 244 | + | targetTab.mu.Unlock() | |||
| 245 | + | if view.windowRows <= 0 { | |||
| 246 | + | targetTab.mu.Lock() | |||
| 247 | + | view.windowRows = targetTab.size.Rows | |||
| 248 | + | targetTab.mu.Unlock() | |||
| 249 | + | } | |||
| 250 | + | if view.windowTop < 0 { | |||
| 251 | + | view.windowTop = 0 | |||
| 252 | + | } | |||
| 253 | + | if tabRepaired || paneRepaired || targetPane == nil { | |||
| 254 | + | view.bookmark = 0 | |||
| 255 | + | view.liveBottom = true | |||
| 256 | + | } | |||
| 257 | + | return view | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | // repairAttachmentView repairs ac and increments its revision only when the | |||
| 261 | + | // stable target changed. It is deliberately separate from registration so | |||
| 262 | + | // layout mutation code can validate every surviving attachment in one ordered | |||
| 263 | + | // pass. | |||
| 264 | + | func (s *session) repairAttachmentView(ac *attachedClient) bool { | |||
| 265 | + | if s == nil || ac == nil { | |||
| 266 | + | return false | |||
| 267 | + | } | |||
| 268 | + | s.mu.Lock() | |||
| 269 | + | defer s.mu.Unlock() | |||
| 270 | + | if _, ok := s.attachments[ac]; !ok { | |||
| 271 | + | return false | |||
| 272 | + | } | |||
| 273 | + | before := ac.viewSnapshot() | |||
| 274 | + | after := s.repairAttachmentViewLocked(ac, before) | |||
| 275 | + | if before.tabID == after.tabID && before.paneID == after.paneID && before.windowTop == after.windowTop && before.windowRows == after.windowRows && before.bookmark == after.bookmark && before.liveBottom == after.liveBottom { | |||
| 276 | + | return false | |||
| 277 | + | } | |||
| 278 | + | after.revision++ | |||
| 279 | + | ac.publishView(after) | |||
| 280 | + | return true | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | // invalidateViewsLocked orders all per-attachment invalidations behind the | |||
| 284 | + | // shared session mutation. Caller holds s.mu and dispatchMu. | |||
| 285 | + | func (s *session) invalidateViewsLocked() []viewInvalidation { | |||
| 286 | + | attachments := s.snapshotAttachmentsLocked() | |||
| 287 | + | invalidations := make([]viewInvalidation, 0, len(attachments)) | |||
| 288 | + | for _, ac := range attachments { | |||
| 289 | + | view := ac.viewSnapshot() | |||
| 290 | + | repaired := s.repairAttachmentViewLocked(ac, view) | |||
| 291 | + | repaired.revision++ | |||
| 292 | + | ac.publishView(repaired) | |||
| 293 | + | invalidations = append(invalidations, viewInvalidation{attachment: ac, revision: repaired.revision}) | |||
| 294 | + | } | |||
| 295 | + | return invalidations | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | // runMutation is the one admission boundary for shared session mutations. | |||
| 299 | + | // Callers perform their state mutation and view invalidation inside fn; the | |||
| 300 | + | // dispatch lock is acquired before any session, tab, or daemon lock. | |||
| 301 | + | func (s *session) runMutation(fn func() error) error { | |||
| 302 | + | if s == nil || fn == nil { | |||
| 303 | + | return nil | |||
| 304 | + | } | |||
| 305 | + | s.dispatchMu.Lock() | |||
| 306 | + | defer s.dispatchMu.Unlock() | |||
| 307 | + | return fn() | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | // tabForAttachment resolves the attachment's stable target after validating | |||
| 311 | + | // it against current membership. It never changes another attachment's view. | |||
| 312 | + | func (s *session) tabForAttachment(ac *attachedClient) *tab { | |||
| 313 | + | if s == nil || ac == nil { | |||
| 314 | + | return nil | |||
| 315 | + | } | |||
| 316 | + | s.repairAttachmentView(ac) | |||
| 317 | + | s.mu.Lock() | |||
| 318 | + | defer s.mu.Unlock() | |||
| 319 | + | if _, ok := s.attachments[ac]; !ok { | |||
| 320 | + | return nil | |||
| 321 | + | } | |||
| 322 | + | view := ac.viewSnapshot() | |||
| 323 | + | repaired := s.repairAttachmentViewLocked(ac, view) | |||
| 324 | + | if repaired.tabID != view.tabID || repaired.paneID != view.paneID || repaired.windowTop != view.windowTop || repaired.windowRows != view.windowRows || repaired.bookmark != view.bookmark || repaired.liveBottom != view.liveBottom { | |||
| 325 | + | repaired.revision++ | |||
| 326 | + | ac.publishView(repaired) | |||
| 327 | + | } | |||
| 328 | + | for _, tb := range s.tabs { | |||
| 329 | + | if tb != nil && domain.TabStableID(tb.stableID) == repaired.tabID { | |||
| 330 | + | return tb | |||
| 331 | + | } | |||
| 332 | + | } | |||
| 333 | + | return nil | |||
| 334 | + | } | |||
| 335 | + | ||||
| 336 | + | // paneForAttachment resolves both stable IDs and repairs a removed pane before | |||
| 337 | + | // returning it. The tab remains shared; only the attachment's view moves. | |||
| 338 | + | func (s *session) tabForAttachmentOrActive(ac *attachedClient) *tab { | |||
| 339 | + | if tb := s.tabForAttachment(ac); tb != nil { | |||
| 340 | + | return tb | |||
| 341 | + | } | |||
| 342 | + | return s.activeTab() | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | func (s *session) paneForAttachment(ac *attachedClient) (*tab, *pane) { | |||
| 346 | + | tb := s.tabForAttachment(ac) | |||
| 347 | + | if tb == nil { | |||
| 348 | + | return nil, nil | |||
| 349 | + | } | |||
| 350 | + | view := ac.viewSnapshot() | |||
| 351 | + | tb.mu.Lock() | |||
| 352 | + | defer tb.mu.Unlock() | |||
| 353 | + | for _, p := range tb.panes { | |||
| 354 | + | if p != nil && domain.PaneStableID(p.stableID) == view.paneID { | |||
| 355 | + | return tb, p | |||
| 356 | + | } | |||
| 357 | + | } | |||
| 358 | + | return tb, tb.focusedPane() | |||
| 359 | + | } | |||
| 360 | + | ||||
| 361 | + | // updateAttachmentView applies one attachment-local view mutation at the | |||
| 362 | + | // shared mutation boundary. The revision increments exactly once and target | |||
| 363 | + | // IDs are repaired before publication. | |||
| 364 | + | func (s *session) updateAttachmentView(ac *attachedClient, update func(*attachmentView)) bool { | |||
| 365 | + | if s == nil || ac == nil || update == nil { | |||
| 366 | + | return false | |||
| 367 | + | } | |||
| 368 | + | changed := false | |||
| 369 | + | _ = s.runMutation(func() error { | |||
| 370 | + | s.mu.Lock() | |||
| 371 | + | defer s.mu.Unlock() | |||
| 372 | + | if _, ok := s.attachments[ac]; !ok { | |||
| 373 | + | return nil | |||
| 374 | + | } | |||
| 375 | + | before := ac.viewSnapshot() | |||
| 376 | + | next := before | |||
| 377 | + | update(&next) | |||
| 378 | + | next = s.repairAttachmentViewLocked(ac, next) | |||
| 379 | + | next.revision++ | |||
| 380 | + | ac.publishView(next) | |||
| 381 | + | changed = next.tabID != before.tabID || next.paneID != before.paneID || next.windowTop != before.windowTop || next.windowRows != before.windowRows || next.bookmark != before.bookmark || next.liveBottom != before.liveBottom | |||
| 382 | + | return nil | |||
| 383 | + | }) | |||
| 384 | + | return changed | |||
| 385 | + | } | |||
| 386 | + | ||||
| 387 | + | // selectAttachmentTab changes only one attachment's target. Shared tab order | |||
| 388 | + | // and PTY ownership are untouched. | |||
| 389 | + | func (s *session) selectAttachmentTab(ac *attachedClient, tabID domain.TabStableID) bool { | |||
| 390 | + | if s == nil || ac == nil { | |||
| 391 | + | return false | |||
| 392 | + | } | |||
| 393 | + | var changed bool | |||
| 394 | + | _ = s.runMutation(func() error { | |||
| 395 | + | s.mu.Lock() | |||
| 396 | + | defer s.mu.Unlock() | |||
| 397 | + | if _, ok := s.attachments[ac]; !ok { | |||
| 398 | + | return nil | |||
| 399 | + | } | |||
| 400 | + | view := ac.viewSnapshot() | |||
| 401 | + | view.tabID = tabID | |||
| 402 | + | view.paneID = "" | |||
| 403 | + | view = s.repairAttachmentViewLocked(ac, view) | |||
| 404 | + | view.revision++ | |||
| 405 | + | ac.publishView(view) | |||
| 406 | + | changed = view.tabID == tabID | |||
| 407 | + | s.invalidateViewsLocked() | |||
| 408 | + | return nil | |||
| 409 | + | }) | |||
| 410 | + | return changed | |||
| 411 | + | } | |||
| 412 | + | ||||
| 413 | + | func (s *session) switchAttachmentTab(ac *attachedClient, idx int) bool { | |||
| 414 | + | if s == nil || ac == nil { | |||
| 415 | + | return false | |||
| 416 | + | } | |||
| 417 | + | s.mu.Lock() | |||
| 418 | + | registered := false | |||
| 419 | + | if s.attachments != nil { | |||
| 420 | + | _, registered = s.attachments[ac] | |||
| 421 | + | } | |||
| 422 | + | if registered && idx >= 0 && idx < len(s.tabs) { | |||
| 423 | + | tabID := domain.TabStableID(s.tabs[idx].stableID) | |||
| 424 | + | s.mu.Unlock() | |||
| 425 | + | return s.selectAttachmentTab(ac, tabID) | |||
| 426 | + | } | |||
| 427 | + | s.mu.Unlock() | |||
| 428 | + | return s.switchTab(idx) | |||
| 429 | + | } | |||
| 430 | + | ||||
| 431 | + | // repairAttachmentViewsLocked repairs all live attachment targets after a tab | |||
| 432 | + | // or pane mutation. It is intended to run at the same mutation linearization | |||
| 433 | + | // point as the shared membership change. | |||
| 434 | + | func (s *session) repairAttachmentViewsLocked() []viewInvalidation { | |||
| 435 | + | if s == nil { | |||
| 436 | + | return nil | |||
| 437 | + | } | |||
| 438 | + | return s.invalidateViewsLocked() | |||
| 439 | + | } | |||
A internal/usecase/daemon/attachment_registry_test.go +138 -0
| @@ -0,0 +1,138 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func registryTestSession() *session { | |
| 11 | + | sz := domain.Size{Cols: 80, Rows: 23} | |
| 12 | + | first := newTabWithStableID("tab-a", "pane-a", nil, sz) | |
| 13 | + | second := newTabWithStableID("tab-b", "pane-b", nil, sz) | |
| 14 | + | return &session{ | |
| 15 | + | sessionCore: sessionCore{id: "registry-test"}, | |
| 16 | + | tabs: []*tab{first, second}, | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func registryTestAttachment(id byte) *attachedClient { | |
| 21 | + | ac := &attachedClient{} | |
| 22 | + | ac.clientID[0] = id | |
| 23 | + | return ac | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestAttachmentRegistryPreservesOtherAttachmentsOnDetach(t *testing.T) { | |
| 27 | + | sess := registryTestSession() | |
| 28 | + | first, second, third := registryTestAttachment(1), registryTestAttachment(2), registryTestAttachment(3) | |
| 29 | + | ||
| 30 | + | for _, ac := range []*attachedClient{first, second, third} { | |
| 31 | + | if !sess.registerAttachment(ac) { | |
| 32 | + | t.Fatalf("register %v failed", ac.clientID[0]) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | if got := sess.snapshotAttachments(); len(got) != 3 { | |
| 36 | + | t.Fatalf("attachment count = %d, want 3", len(got)) | |
| 37 | + | } | |
| 38 | + | if !sess.unregisterAttachment(second) { | |
| 39 | + | t.Fatal("unregister second failed") | |
| 40 | + | } | |
| 41 | + | got := sess.snapshotAttachments() | |
| 42 | + | if len(got) != 2 || got[0] != first || got[1] != third { | |
| 43 | + | t.Fatalf("remaining attachments = %v, want first and third", got) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestAttachmentRegistryRepairsRemovedStableTarget(t *testing.T) { | |
| 48 | + | sess := registryTestSession() | |
| 49 | + | ac := registryTestAttachment(1) | |
| 50 | + | if !sess.registerAttachment(ac) || !sess.selectAttachmentTab(ac, "tab-b") { | |
| 51 | + | t.Fatal("failed to select second tab") | |
| 52 | + | } | |
| 53 | + | before := ac.viewSnapshot() | |
| 54 | + | if before.tabID != "tab-b" || before.paneID != "pane-b" { | |
| 55 | + | t.Fatalf("selected view = %#v", before) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | if err := sess.runMutation(func() error { | |
| 59 | + | sess.mu.Lock() | |
| 60 | + | defer sess.mu.Unlock() | |
| 61 | + | sess.tabs = sess.tabs[:1] | |
| 62 | + | sess.repairAttachmentViewsLocked() | |
| 63 | + | return nil | |
| 64 | + | }); err != nil { | |
| 65 | + | t.Fatal(err) | |
| 66 | + | } | |
| 67 | + | after := ac.viewSnapshot() | |
| 68 | + | if after.tabID != "tab-a" || after.paneID != "pane-a" { | |
| 69 | + | t.Fatalf("repaired view = %#v, want tab-a/pane-a", after) | |
| 70 | + | } | |
| 71 | + | if after.revision <= before.revision || !after.liveBottom { | |
| 72 | + | t.Fatalf("repaired view revision/liveBottom = %d/%v, want revision increase and live bottom", after.revision, after.liveBottom) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestAttachmentRegistryViewMutationIsSerialized(t *testing.T) { | |
| 77 | + | sess := registryTestSession() | |
| 78 | + | ac := registryTestAttachment(1) | |
| 79 | + | if !sess.registerAttachment(ac) { | |
| 80 | + | t.Fatal("register failed") | |
| 81 | + | } | |
| 82 | + | before := ac.viewSnapshot() | |
| 83 | + | if !sess.updateAttachmentView(ac, func(view *attachmentView) { | |
| 84 | + | view.windowTop = 4 | |
| 85 | + | view.windowRows = 12 | |
| 86 | + | view.bookmark = 9 | |
| 87 | + | view.liveBottom = false | |
| 88 | + | }) { | |
| 89 | + | t.Fatal("view update was not published") | |
| 90 | + | } | |
| 91 | + | after := ac.viewSnapshot() | |
| 92 | + | if after.windowTop != 4 || after.windowRows != 12 || after.bookmark != 9 || after.liveBottom || after.revision != before.revision+1 { | |
| 93 | + | t.Fatalf("view after mutation = %#v, want updated fields and one revision", after) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestAttachmentRegistryConcurrentRegistrationSnapshotIsStable(t *testing.T) { | |
| 98 | + | sess := registryTestSession() | |
| 99 | + | attachments := []*attachedClient{ | |
| 100 | + | registryTestAttachment(3), | |
| 101 | + | registryTestAttachment(1), | |
| 102 | + | registryTestAttachment(2), | |
| 103 | + | } | |
| 104 | + | start := make(chan struct{}) | |
| 105 | + | var wg sync.WaitGroup | |
| 106 | + | for _, ac := range attachments { | |
| 107 | + | wg.Add(1) | |
| 108 | + | go func(ac *attachedClient) { | |
| 109 | + | defer wg.Done() | |
| 110 | + | <-start | |
| 111 | + | sess.registerAttachment(ac) | |
| 112 | + | }(ac) | |
| 113 | + | } | |
| 114 | + | close(start) | |
| 115 | + | wg.Wait() | |
| 116 | + | ||
| 117 | + | got := sess.snapshotAttachments() | |
| 118 | + | if len(got) != len(attachments) { | |
| 119 | + | t.Fatalf("attachment count = %d, want %d", len(got), len(attachments)) | |
| 120 | + | } | |
| 121 | + | for i, want := range []byte{1, 2, 3} { | |
| 122 | + | if got[i].clientID[0] != want { | |
| 123 | + | t.Fatalf("snapshot[%d] id = %d, want %d", i, got[i].clientID[0], want) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | invalidations := sess.repairAttachmentViewsLockedForTest() | |
| 127 | + | for i, want := range []byte{1, 2, 3} { | |
| 128 | + | if invalidations[i].attachment.clientID[0] != want { | |
| 129 | + | t.Fatalf("invalidation[%d] id = %d, want %d", i, invalidations[i].attachment.clientID[0], want) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func (s *session) repairAttachmentViewsLockedForTest() []viewInvalidation { | |
| 135 | + | s.mu.Lock() | |
| 136 | + | defer s.mu.Unlock() | |
| 137 | + | return s.repairAttachmentViewsLocked() | |
| 138 | + | } | |
| @@ -0,0 +1,138 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sync" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func registryTestSession() *session { | |||
| 11 | + | sz := domain.Size{Cols: 80, Rows: 23} | |||
| 12 | + | first := newTabWithStableID("tab-a", "pane-a", nil, sz) | |||
| 13 | + | second := newTabWithStableID("tab-b", "pane-b", nil, sz) | |||
| 14 | + | return &session{ | |||
| 15 | + | sessionCore: sessionCore{id: "registry-test"}, | |||
| 16 | + | tabs: []*tab{first, second}, | |||
| 17 | + | } | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func registryTestAttachment(id byte) *attachedClient { | |||
| 21 | + | ac := &attachedClient{} | |||
| 22 | + | ac.clientID[0] = id | |||
| 23 | + | return ac | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func TestAttachmentRegistryPreservesOtherAttachmentsOnDetach(t *testing.T) { | |||
| 27 | + | sess := registryTestSession() | |||
| 28 | + | first, second, third := registryTestAttachment(1), registryTestAttachment(2), registryTestAttachment(3) | |||
| 29 | + | ||||
| 30 | + | for _, ac := range []*attachedClient{first, second, third} { | |||
| 31 | + | if !sess.registerAttachment(ac) { | |||
| 32 | + | t.Fatalf("register %v failed", ac.clientID[0]) | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | if got := sess.snapshotAttachments(); len(got) != 3 { | |||
| 36 | + | t.Fatalf("attachment count = %d, want 3", len(got)) | |||
| 37 | + | } | |||
| 38 | + | if !sess.unregisterAttachment(second) { | |||
| 39 | + | t.Fatal("unregister second failed") | |||
| 40 | + | } | |||
| 41 | + | got := sess.snapshotAttachments() | |||
| 42 | + | if len(got) != 2 || got[0] != first || got[1] != third { | |||
| 43 | + | t.Fatalf("remaining attachments = %v, want first and third", got) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestAttachmentRegistryRepairsRemovedStableTarget(t *testing.T) { | |||
| 48 | + | sess := registryTestSession() | |||
| 49 | + | ac := registryTestAttachment(1) | |||
| 50 | + | if !sess.registerAttachment(ac) || !sess.selectAttachmentTab(ac, "tab-b") { | |||
| 51 | + | t.Fatal("failed to select second tab") | |||
| 52 | + | } | |||
| 53 | + | before := ac.viewSnapshot() | |||
| 54 | + | if before.tabID != "tab-b" || before.paneID != "pane-b" { | |||
| 55 | + | t.Fatalf("selected view = %#v", before) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | if err := sess.runMutation(func() error { | |||
| 59 | + | sess.mu.Lock() | |||
| 60 | + | defer sess.mu.Unlock() | |||
| 61 | + | sess.tabs = sess.tabs[:1] | |||
| 62 | + | sess.repairAttachmentViewsLocked() | |||
| 63 | + | return nil | |||
| 64 | + | }); err != nil { | |||
| 65 | + | t.Fatal(err) | |||
| 66 | + | } | |||
| 67 | + | after := ac.viewSnapshot() | |||
| 68 | + | if after.tabID != "tab-a" || after.paneID != "pane-a" { | |||
| 69 | + | t.Fatalf("repaired view = %#v, want tab-a/pane-a", after) | |||
| 70 | + | } | |||
| 71 | + | if after.revision <= before.revision || !after.liveBottom { | |||
| 72 | + | t.Fatalf("repaired view revision/liveBottom = %d/%v, want revision increase and live bottom", after.revision, after.liveBottom) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestAttachmentRegistryViewMutationIsSerialized(t *testing.T) { | |||
| 77 | + | sess := registryTestSession() | |||
| 78 | + | ac := registryTestAttachment(1) | |||
| 79 | + | if !sess.registerAttachment(ac) { | |||
| 80 | + | t.Fatal("register failed") | |||
| 81 | + | } | |||
| 82 | + | before := ac.viewSnapshot() | |||
| 83 | + | if !sess.updateAttachmentView(ac, func(view *attachmentView) { | |||
| 84 | + | view.windowTop = 4 | |||
| 85 | + | view.windowRows = 12 | |||
| 86 | + | view.bookmark = 9 | |||
| 87 | + | view.liveBottom = false | |||
| 88 | + | }) { | |||
| 89 | + | t.Fatal("view update was not published") | |||
| 90 | + | } | |||
| 91 | + | after := ac.viewSnapshot() | |||
| 92 | + | if after.windowTop != 4 || after.windowRows != 12 || after.bookmark != 9 || after.liveBottom || after.revision != before.revision+1 { | |||
| 93 | + | t.Fatalf("view after mutation = %#v, want updated fields and one revision", after) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestAttachmentRegistryConcurrentRegistrationSnapshotIsStable(t *testing.T) { | |||
| 98 | + | sess := registryTestSession() | |||
| 99 | + | attachments := []*attachedClient{ | |||
| 100 | + | registryTestAttachment(3), | |||
| 101 | + | registryTestAttachment(1), | |||
| 102 | + | registryTestAttachment(2), | |||
| 103 | + | } | |||
| 104 | + | start := make(chan struct{}) | |||
| 105 | + | var wg sync.WaitGroup | |||
| 106 | + | for _, ac := range attachments { | |||
| 107 | + | wg.Add(1) | |||
| 108 | + | go func(ac *attachedClient) { | |||
| 109 | + | defer wg.Done() | |||
| 110 | + | <-start | |||
| 111 | + | sess.registerAttachment(ac) | |||
| 112 | + | }(ac) | |||
| 113 | + | } | |||
| 114 | + | close(start) | |||
| 115 | + | wg.Wait() | |||
| 116 | + | ||||
| 117 | + | got := sess.snapshotAttachments() | |||
| 118 | + | if len(got) != len(attachments) { | |||
| 119 | + | t.Fatalf("attachment count = %d, want %d", len(got), len(attachments)) | |||
| 120 | + | } | |||
| 121 | + | for i, want := range []byte{1, 2, 3} { | |||
| 122 | + | if got[i].clientID[0] != want { | |||
| 123 | + | t.Fatalf("snapshot[%d] id = %d, want %d", i, got[i].clientID[0], want) | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | invalidations := sess.repairAttachmentViewsLockedForTest() | |||
| 127 | + | for i, want := range []byte{1, 2, 3} { | |||
| 128 | + | if invalidations[i].attachment.clientID[0] != want { | |||
| 129 | + | t.Fatalf("invalidation[%d] id = %d, want %d", i, invalidations[i].attachment.clientID[0], want) | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func (s *session) repairAttachmentViewsLockedForTest() []viewInvalidation { | |||
| 135 | + | s.mu.Lock() | |||
| 136 | + | defer s.mu.Unlock() | |||
| 137 | + | return s.repairAttachmentViewsLocked() | |||
| 138 | + | } | |||
M internal/usecase/daemon/attachment_transition_publish.go +9 -0
| @@ -196,6 +196,14 @@ func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool | |||
| 196 | 196 | // generations, and currentSession while all role gates remain frozen. | |
| 197 | 197 | func publishAttachmentOwnershipLocked(publication *attachmentPublication) { | |
| 198 | 198 | req := publication.req | |
| 199 | + | // Membership is collection-owned and is published before role metadata. A | |
| 200 | + | // second attachment therefore coexists with the first even when the legacy | |
| 201 | + | // active/snatched handoff state is also updated for older render paths. | |
| 202 | + | registerAttachmentSessionLocked(publication.source, req.next) | |
| 203 | + | registerAttachmentSessionLocked(req.target, req.next) | |
| 204 | + | if publication.old != nil && publication.old != req.next { | |
| 205 | + | registerAttachmentSessionLocked(req.target, publication.old) | |
| 206 | + | } | |
| 199 | 207 | if req.targetRole == attachmentActive && publication.old != nil && publication.old != req.next { | |
| 200 | 208 | publication.displacedTransport = publication.old.transportSnapshot() | |
| 201 | 209 | // A proxied daemon attachment has no local snatched UI and must never | |
| @@ -214,6 +222,7 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) { | |||
| 214 | 222 | if publication.old != nil && publication.old != req.next { | |
| 215 | 223 | if publication.terminalDisplaced { | |
| 216 | 224 | delete(req.target.core().snatched, publication.old) | |
| 225 | + | req.target.core().unregisterAttachmentLocked(publication.old) | |
| 217 | 226 | } else { | |
| 218 | 227 | addSnatchedLocked(req.target, publication.old) | |
| 219 | 228 | } | |
| @@ -196,6 +196,14 @@ func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool | |||||
| 196 | // generations, and currentSession while all role gates remain frozen. | 196 | // generations, and currentSession while all role gates remain frozen. | ||
| 197 | func publishAttachmentOwnershipLocked(publication *attachmentPublication) { | 197 | func publishAttachmentOwnershipLocked(publication *attachmentPublication) { | ||
| 198 | req := publication.req | 198 | req := publication.req | ||
| 199 | + | // Membership is collection-owned and is published before role metadata. A | |||
| 200 | + | // second attachment therefore coexists with the first even when the legacy | |||
| 201 | + | // active/snatched handoff state is also updated for older render paths. | |||
| 202 | + | registerAttachmentSessionLocked(publication.source, req.next) | |||
| 203 | + | registerAttachmentSessionLocked(req.target, req.next) | |||
| 204 | + | if publication.old != nil && publication.old != req.next { | |||
| 205 | + | registerAttachmentSessionLocked(req.target, publication.old) | |||
| 206 | + | } | |||
| 199 | if req.targetRole == attachmentActive && publication.old != nil && publication.old != req.next { | 207 | if req.targetRole == attachmentActive && publication.old != nil && publication.old != req.next { | ||
| 200 | publication.displacedTransport = publication.old.transportSnapshot() | 208 | publication.displacedTransport = publication.old.transportSnapshot() | ||
| 201 | // A proxied daemon attachment has no local snatched UI and must never | 209 | // A proxied daemon attachment has no local snatched UI and must never | ||
| @@ -214,6 +222,7 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) { | |||||
| 214 | if publication.old != nil && publication.old != req.next { | 222 | if publication.old != nil && publication.old != req.next { | ||
| 215 | if publication.terminalDisplaced { | 223 | if publication.terminalDisplaced { | ||
| 216 | delete(req.target.core().snatched, publication.old) | 224 | delete(req.target.core().snatched, publication.old) | ||
| 225 | + | req.target.core().unregisterAttachmentLocked(publication.old) | |||
| 217 | } else { | 226 | } else { | ||
| 218 | addSnatchedLocked(req.target, publication.old) | 227 | addSnatchedLocked(req.target, publication.old) | ||
| 219 | } | 228 | } | ||
M internal/usecase/daemon/client.go +12 -7
| @@ -83,13 +83,18 @@ type attachedClient struct { | |||
| 83 | 83 | initialSnatchedAttempt *initialSnatchedPanelAttempt | |
| 84 | 84 | size domain.Size | |
| 85 | 85 | keys *keys.Router | |
| 86 | - | sess Guarded[attachmentSession] | |
| 87 | - | mouseScan mouse.Scanner | |
| 88 | - | themeMu sync.Mutex | |
| 89 | - | clientTheme themeui.Theme | |
| 90 | - | appliedTheme appliedTheme | |
| 91 | - | lastCursor cursorOut | |
| 92 | - | renderStages renderStageHooks // optional render and handoff observability hooks | |
| 86 | + | // view is attachment-local navigation state. It is never inferred from a | |
| 87 | + | // session-wide active tab, so multiple attachments can observe different | |
| 88 | + | // tabs and panes without changing shared session ownership. | |
| 89 | + | viewMu sync.Mutex | |
| 90 | + | view attachmentView | |
| 91 | + | sess Guarded[attachmentSession] | |
| 92 | + | mouseScan mouse.Scanner | |
| 93 | + | themeMu sync.Mutex | |
| 94 | + | clientTheme themeui.Theme | |
| 95 | + | appliedTheme appliedTheme | |
| 96 | + | lastCursor cursorOut | |
| 97 | + | renderStages renderStageHooks // optional render and handoff observability hooks | |
| 93 | 98 | // previousSession is guarded independently. It is retained through temporary | |
| 94 | 99 | // setSession(nil) hand-offs and cleared only on terminal teardown. | |
| 95 | 100 | previousSession Guarded[attachmentSession] | |
| @@ -83,13 +83,18 @@ type attachedClient struct { | |||||
| 83 | initialSnatchedAttempt *initialSnatchedPanelAttempt | 83 | initialSnatchedAttempt *initialSnatchedPanelAttempt | ||
| 84 | size domain.Size | 84 | size domain.Size | ||
| 85 | keys *keys.Router | 85 | keys *keys.Router | ||
| 86 | - | sess Guarded[attachmentSession] | 86 | + | // view is attachment-local navigation state. It is never inferred from a |
| 87 | - | mouseScan mouse.Scanner | 87 | + | // session-wide active tab, so multiple attachments can observe different |
| 88 | - | themeMu sync.Mutex | 88 | + | // tabs and panes without changing shared session ownership. |
| 89 | - | clientTheme themeui.Theme | 89 | + | viewMu sync.Mutex |
| 90 | - | appliedTheme appliedTheme | 90 | + | view attachmentView |
| 91 | - | lastCursor cursorOut | 91 | + | sess Guarded[attachmentSession] |
| 92 | - | renderStages renderStageHooks // optional render and handoff observability hooks | 92 | + | mouseScan mouse.Scanner |
| 93 | + | themeMu sync.Mutex | |||
| 94 | + | clientTheme themeui.Theme | |||
| 95 | + | appliedTheme appliedTheme | |||
| 96 | + | lastCursor cursorOut | |||
| 97 | + | renderStages renderStageHooks // optional render and handoff observability hooks | |||
| 93 | // previousSession is guarded independently. It is retained through temporary | 98 | // previousSession is guarded independently. It is retained through temporary | ||
| 94 | // setSession(nil) hand-offs and cleared only on terminal teardown. | 99 | // setSession(nil) hand-offs and cleared only on terminal teardown. | ||
| 95 | previousSession Guarded[attachmentSession] | 100 | previousSession Guarded[attachmentSession] | ||
M internal/usecase/daemon/clipboard.go +5 -1
| @@ -143,7 +143,11 @@ func (d *Daemon) injectClipboardPathForRole(token attachmentRoleToken, path stri | |||
| 143 | 143 | } | |
| 144 | 144 | ||
| 145 | 145 | func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *attachmentRoleToken) { | |
| 146 | - | tb := sess.activeTab() | |
| 146 | + | var ac *attachedClient | |
| 147 | + | if token != nil { | |
| 148 | + | ac = token.ac | |
| 149 | + | } | |
| 150 | + | tb := sess.tabForAttachmentOrActive(ac) | |
| 147 | 151 | if tb == nil { | |
| 148 | 152 | return | |
| 149 | 153 | } | |
| @@ -143,7 +143,11 @@ func (d *Daemon) injectClipboardPathForRole(token attachmentRoleToken, path stri | |||||
| 143 | } | 143 | } | ||
| 144 | 144 | ||||
| 145 | func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *attachmentRoleToken) { | 145 | func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *attachmentRoleToken) { | ||
| 146 | - | tb := sess.activeTab() | 146 | + | var ac *attachedClient |
| 147 | + | if token != nil { | |||
| 148 | + | ac = token.ac | |||
| 149 | + | } | |||
| 150 | + | tb := sess.tabForAttachmentOrActive(ac) | |||
| 147 | if tb == nil { | 151 | if tb == nil { | ||
| 148 | return | 152 | return | ||
| 149 | } | 153 | } | ||
M internal/usecase/daemon/control.go +5 -1
| @@ -269,10 +269,14 @@ type daemonActionRunner interface { | |||
| 269 | 269 | } | |
| 270 | 270 | ||
| 271 | 271 | func resolveDaemonActionTarget(sess *session) daemonActionTarget { | |
| 272 | + | return resolveDaemonActionTargetForAttachment(sess, nil) | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | func resolveDaemonActionTargetForAttachment(sess *session, ac *attachedClient) daemonActionTarget { | |
| 272 | 276 | if sess == nil { | |
| 273 | 277 | return daemonActionTarget{} | |
| 274 | 278 | } | |
| 275 | - | tb := sess.activeTab() | |
| 279 | + | tb := sess.tabForAttachmentOrActive(ac) | |
| 276 | 280 | if tb == nil { | |
| 277 | 281 | return daemonActionTarget{session: sess} | |
| 278 | 282 | } | |
| @@ -269,10 +269,14 @@ type daemonActionRunner interface { | |||||
| 269 | } | 269 | } | ||
| 270 | 270 | ||||
| 271 | func resolveDaemonActionTarget(sess *session) daemonActionTarget { | 271 | func resolveDaemonActionTarget(sess *session) daemonActionTarget { | ||
| 272 | + | return resolveDaemonActionTargetForAttachment(sess, nil) | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | func resolveDaemonActionTargetForAttachment(sess *session, ac *attachedClient) daemonActionTarget { | |||
| 272 | if sess == nil { | 276 | if sess == nil { | ||
| 273 | return daemonActionTarget{} | 277 | return daemonActionTarget{} | ||
| 274 | } | 278 | } | ||
| 275 | - | tb := sess.activeTab() | 279 | + | tb := sess.tabForAttachmentOrActive(ac) |
| 276 | if tb == nil { | 280 | if tb == nil { | ||
| 277 | return daemonActionTarget{session: sess} | 281 | return daemonActionTarget{session: sess} | ||
| 278 | } | 282 | } | ||
M internal/usecase/daemon/copy_mouse_input.go +1 -1
| @@ -179,7 +179,7 @@ func (d *Daemon) handleFreshCopyPointer(sess *session, ac *attachedClient, ev mo | |||
| 179 | 179 | if !ok { | |
| 180 | 180 | return true | |
| 181 | 181 | } | |
| 182 | - | if !d.publishCopyMode(sess, ac, sess.activeTab(), pointer.pane, pointer.document, func(mode *scopy.Mode) { | |
| 182 | + | if !d.publishCopyMode(sess, ac, sess.tabForAttachmentOrActive(ac), pointer.pane, pointer.document, func(mode *scopy.Mode) { | |
| 183 | 183 | mode.StartCharacterSelection(pointer.press) | |
| 184 | 184 | mode.ExtendCharacterSelection(mapped.pos) | |
| 185 | 185 | }, func(runtime *overlayRuntime, mode *scopy.Mode) { | |
| @@ -179,7 +179,7 @@ func (d *Daemon) handleFreshCopyPointer(sess *session, ac *attachedClient, ev mo | |||||
| 179 | if !ok { | 179 | if !ok { | ||
| 180 | return true | 180 | return true | ||
| 181 | } | 181 | } | ||
| 182 | - | if !d.publishCopyMode(sess, ac, sess.activeTab(), pointer.pane, pointer.document, func(mode *scopy.Mode) { | 182 | + | if !d.publishCopyMode(sess, ac, sess.tabForAttachmentOrActive(ac), pointer.pane, pointer.document, func(mode *scopy.Mode) { |
| 183 | mode.StartCharacterSelection(pointer.press) | 183 | mode.StartCharacterSelection(pointer.press) | ||
| 184 | mode.ExtendCharacterSelection(mapped.pos) | 184 | mode.ExtendCharacterSelection(mapped.pos) | ||
| 185 | }, func(runtime *overlayRuntime, mode *scopy.Mode) { | 185 | }, func(runtime *overlayRuntime, mode *scopy.Mode) { | ||
M internal/usecase/daemon/copymode.go +2 -2
| @@ -63,7 +63,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |||
| 63 | 63 | d.invalidateRender(sess, ac, true, "copymode.go") | |
| 64 | 64 | } | |
| 65 | 65 | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |
| 66 | - | tb := sess.activeTab() | |
| 66 | + | tb := sess.tabForAttachmentOrActive(ac) | |
| 67 | 67 | if tb == nil { | |
| 68 | 68 | return | |
| 69 | 69 | } | |
| @@ -114,7 +114,7 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p * | |||
| 114 | 114 | if d.beforeCopyModeRevalidate != nil { | |
| 115 | 115 | d.beforeCopyModeRevalidate() | |
| 116 | 116 | } | |
| 117 | - | valid := sess.activeTab() == tb | |
| 117 | + | valid := sess.tabForAttachmentOrActive(ac) == tb | |
| 118 | 118 | if valid { | |
| 119 | 119 | tb.mu.Lock() | |
| 120 | 120 | valid = tb.panes[p.id] == p || (tb.floating.state == floatingVisible && tb.floating.pane == p) | |
| @@ -63,7 +63,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |||||
| 63 | d.invalidateRender(sess, ac, true, "copymode.go") | 63 | d.invalidateRender(sess, ac, true, "copymode.go") | ||
| 64 | } | 64 | } | ||
| 65 | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | 65 | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | ||
| 66 | - | tb := sess.activeTab() | 66 | + | tb := sess.tabForAttachmentOrActive(ac) |
| 67 | if tb == nil { | 67 | if tb == nil { | ||
| 68 | return | 68 | return | ||
| 69 | } | 69 | } | ||
| @@ -114,7 +114,7 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p * | |||||
| 114 | if d.beforeCopyModeRevalidate != nil { | 114 | if d.beforeCopyModeRevalidate != nil { | ||
| 115 | d.beforeCopyModeRevalidate() | 115 | d.beforeCopyModeRevalidate() | ||
| 116 | } | 116 | } | ||
| 117 | - | valid := sess.activeTab() == tb | 117 | + | valid := sess.tabForAttachmentOrActive(ac) == tb |
| 118 | if valid { | 118 | if valid { | ||
| 119 | tb.mu.Lock() | 119 | tb.mu.Lock() | ||
| 120 | valid = tb.panes[p.id] == p || (tb.floating.state == floatingVisible && tb.floating.pane == p) | 120 | valid = tb.panes[p.id] == p || (tb.floating.state == floatingVisible && tb.floating.pane == p) | ||
M internal/usecase/daemon/floating.go +1 -1
| @@ -222,7 +222,7 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error { | |||
| 222 | 222 | if d == nil || sess == nil { | |
| 223 | 223 | return domain.UserErr(domain.NoticeFloatingSpawn, "couldn't open floating pane: no active session", nil) | |
| 224 | 224 | } | |
| 225 | - | tb := sess.activeTab() | |
| 225 | + | tb := sess.tabForAttachmentOrActive(ac) | |
| 226 | 226 | if tb == nil { | |
| 227 | 227 | return domain.UserErr(domain.NoticeFloatingSpawn, "couldn't open floating pane: no active tab", layout.ErrNotFound) | |
| 228 | 228 | } | |
| @@ -222,7 +222,7 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error { | |||||
| 222 | if d == nil || sess == nil { | 222 | if d == nil || sess == nil { | ||
| 223 | return domain.UserErr(domain.NoticeFloatingSpawn, "couldn't open floating pane: no active session", nil) | 223 | return domain.UserErr(domain.NoticeFloatingSpawn, "couldn't open floating pane: no active session", nil) | ||
| 224 | } | 224 | } | ||
| 225 | - | tb := sess.activeTab() | 225 | + | tb := sess.tabForAttachmentOrActive(ac) |
| 226 | if tb == nil { | 226 | if tb == nil { | ||
| 227 | return domain.UserErr(domain.NoticeFloatingSpawn, "couldn't open floating pane: no active tab", layout.ErrNotFound) | 227 | return domain.UserErr(domain.NoticeFloatingSpawn, "couldn't open floating pane: no active tab", layout.ErrNotFound) | ||
| 228 | } | 228 | } | ||
M internal/usecase/daemon/input.go +7 -7
| @@ -93,7 +93,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 93 | 93 | invalidateRejectedLeftPointer(rt, ev) | |
| 94 | 94 | return | |
| 95 | 95 | } | |
| 96 | - | tb := sess.activeTab() | |
| 96 | + | tb := sess.tabForAttachmentOrActive(ac) | |
| 97 | 97 | if tb == nil { | |
| 98 | 98 | invalidateRejectedLeftPointer(rt, ev) | |
| 99 | 99 | return | |
| @@ -364,7 +364,7 @@ func (h daemonKeyHandler) Forward(data []byte) { | |||
| 364 | 364 | if owned { | |
| 365 | 365 | defer effect.End() | |
| 366 | 366 | } | |
| 367 | - | tb := sess.activeTab() | |
| 367 | + | tb := sess.tabForAttachmentOrActive(h.ac) | |
| 368 | 368 | if tb == nil { | |
| 369 | 369 | return | |
| 370 | 370 | } | |
| @@ -383,7 +383,7 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) { | |||
| 383 | 383 | defer effect.End() | |
| 384 | 384 | } | |
| 385 | 385 | runAction := func(request daemonActionRequest) { | |
| 386 | - | request.target = resolveDaemonActionTarget(sess) | |
| 386 | + | request.target = resolveDaemonActionTargetForAttachment(sess, h.ac) | |
| 387 | 387 | runner := h.actions | |
| 388 | 388 | if runner == nil { | |
| 389 | 389 | runner = daemonActions{d: h.d} | |
| @@ -408,8 +408,8 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) { | |||
| 408 | 408 | h.d.enterPalette(sess, h.ac) | |
| 409 | 409 | case keys.ActionJumpAttention: | |
| 410 | 410 | if !proxiedJumpSearchesOtherSessions(h.ac.proxied) { | |
| 411 | - | if idx, ok := oldestAttentionTab(sess); ok && sess.switchTab(idx) { | |
| 412 | - | h.d.activateTab(sess, sess.activeTab()) | |
| 411 | + | if idx, ok := oldestAttentionTab(sess); ok && sess.switchAttachmentTab(h.ac, idx) { | |
| 412 | + | h.d.activateTabAfterResizeForLease(sess, sess.tabForAttachmentOrActive(h.ac), false, h.ac, nil) | |
| 413 | 413 | h.d.invalidateRender(sess, h.ac, true, "input.go") | |
| 414 | 414 | } | |
| 415 | 415 | return | |
| @@ -457,8 +457,8 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) { | |||
| 457 | 457 | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | |
| 458 | 458 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | |
| 459 | 459 | idx := int(action - keys.ActionSwitchTab1) | |
| 460 | - | if sess.switchTab(idx) { | |
| 461 | - | h.d.activateTab(sess, sess.activeTab()) | |
| 460 | + | if sess.switchAttachmentTab(h.ac, idx) { | |
| 461 | + | h.d.activateTabAfterResizeForLease(sess, sess.tabForAttachmentOrActive(h.ac), false, h.ac, nil) | |
| 462 | 462 | h.d.invalidateRender(sess, h.ac, true, "input.go") | |
| 463 | 463 | } | |
| 464 | 464 | } | |
| @@ -93,7 +93,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 93 | invalidateRejectedLeftPointer(rt, ev) | 93 | invalidateRejectedLeftPointer(rt, ev) | ||
| 94 | return | 94 | return | ||
| 95 | } | 95 | } | ||
| 96 | - | tb := sess.activeTab() | 96 | + | tb := sess.tabForAttachmentOrActive(ac) |
| 97 | if tb == nil { | 97 | if tb == nil { | ||
| 98 | invalidateRejectedLeftPointer(rt, ev) | 98 | invalidateRejectedLeftPointer(rt, ev) | ||
| 99 | return | 99 | return | ||
| @@ -364,7 +364,7 @@ func (h daemonKeyHandler) Forward(data []byte) { | |||||
| 364 | if owned { | 364 | if owned { | ||
| 365 | defer effect.End() | 365 | defer effect.End() | ||
| 366 | } | 366 | } | ||
| 367 | - | tb := sess.activeTab() | 367 | + | tb := sess.tabForAttachmentOrActive(h.ac) |
| 368 | if tb == nil { | 368 | if tb == nil { | ||
| 369 | return | 369 | return | ||
| 370 | } | 370 | } | ||
| @@ -383,7 +383,7 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) { | |||||
| 383 | defer effect.End() | 383 | defer effect.End() | ||
| 384 | } | 384 | } | ||
| 385 | runAction := func(request daemonActionRequest) { | 385 | runAction := func(request daemonActionRequest) { | ||
| 386 | - | request.target = resolveDaemonActionTarget(sess) | 386 | + | request.target = resolveDaemonActionTargetForAttachment(sess, h.ac) |
| 387 | runner := h.actions | 387 | runner := h.actions | ||
| 388 | if runner == nil { | 388 | if runner == nil { | ||
| 389 | runner = daemonActions{d: h.d} | 389 | runner = daemonActions{d: h.d} | ||
| @@ -408,8 +408,8 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) { | |||||
| 408 | h.d.enterPalette(sess, h.ac) | 408 | h.d.enterPalette(sess, h.ac) | ||
| 409 | case keys.ActionJumpAttention: | 409 | case keys.ActionJumpAttention: | ||
| 410 | if !proxiedJumpSearchesOtherSessions(h.ac.proxied) { | 410 | if !proxiedJumpSearchesOtherSessions(h.ac.proxied) { | ||
| 411 | - | if idx, ok := oldestAttentionTab(sess); ok && sess.switchTab(idx) { | 411 | + | if idx, ok := oldestAttentionTab(sess); ok && sess.switchAttachmentTab(h.ac, idx) { |
| 412 | - | h.d.activateTab(sess, sess.activeTab()) | 412 | + | h.d.activateTabAfterResizeForLease(sess, sess.tabForAttachmentOrActive(h.ac), false, h.ac, nil) |
| 413 | h.d.invalidateRender(sess, h.ac, true, "input.go") | 413 | h.d.invalidateRender(sess, h.ac, true, "input.go") | ||
| 414 | } | 414 | } | ||
| 415 | return | 415 | return | ||
| @@ -457,8 +457,8 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) { | |||||
| 457 | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | 457 | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | ||
| 458 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | 458 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | ||
| 459 | idx := int(action - keys.ActionSwitchTab1) | 459 | idx := int(action - keys.ActionSwitchTab1) | ||
| 460 | - | if sess.switchTab(idx) { | 460 | + | if sess.switchAttachmentTab(h.ac, idx) { |
| 461 | - | h.d.activateTab(sess, sess.activeTab()) | 461 | + | h.d.activateTabAfterResizeForLease(sess, sess.tabForAttachmentOrActive(h.ac), false, h.ac, nil) |
| 462 | h.d.invalidateRender(sess, h.ac, true, "input.go") | 462 | h.d.invalidateRender(sess, h.ac, true, "input.go") | ||
| 463 | } | 463 | } | ||
| 464 | } | 464 | } | ||
M internal/usecase/daemon/move_pane_postcommit.go +1 -0
| @@ -153,6 +153,7 @@ func retireEmptyMoveSessionLocked(sess *session, retirement frozenMoveAttachment | |||
| 153 | 153 | for _, ac := range retirement.clients { | |
| 154 | 154 | retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: ac.transportSnapshot()}) | |
| 155 | 155 | delete(sess.snatched, ac) | |
| 156 | + | sess.unregisterAttachmentLocked(ac) | |
| 156 | 157 | ac.roleGeneration.Add(1) | |
| 157 | 158 | ac.setSession(nil) | |
| 158 | 159 | ac.invalidateFrozenRoleCapability() | |
| @@ -153,6 +153,7 @@ func retireEmptyMoveSessionLocked(sess *session, retirement frozenMoveAttachment | |||||
| 153 | for _, ac := range retirement.clients { | 153 | for _, ac := range retirement.clients { | ||
| 154 | retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: ac.transportSnapshot()}) | 154 | retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: ac.transportSnapshot()}) | ||
| 155 | delete(sess.snatched, ac) | 155 | delete(sess.snatched, ac) | ||
| 156 | + | sess.unregisterAttachmentLocked(ac) | |||
| 156 | ac.roleGeneration.Add(1) | 157 | ac.roleGeneration.Add(1) | ||
| 157 | ac.setSession(nil) | 158 | ac.setSession(nil) | ||
| 158 | ac.invalidateFrozenRoleCapability() | 159 | ac.invalidateFrozenRoleCapability() | ||
M internal/usecase/daemon/palette.go +12 -5
| @@ -501,7 +501,7 @@ type paletteExec struct { | |||
| 501 | 501 | ||
| 502 | 502 | func (e paletteExec) runAction(request daemonActionRequest) error { | |
| 503 | 503 | if request.target.session == nil { | |
| 504 | - | request.target = resolveDaemonActionTarget(e.sess) | |
| 504 | + | request.target = resolveDaemonActionTargetForAttachment(e.sess, e.ac) | |
| 505 | 505 | } | |
| 506 | 506 | runner := e.actions | |
| 507 | 507 | if runner == nil { | |
| @@ -580,7 +580,7 @@ func (e paletteExec) ClosePane() error { | |||
| 580 | 580 | } | |
| 581 | 581 | ||
| 582 | 582 | func (e paletteExec) OpenMovePanePicker() error { | |
| 583 | - | target := resolveDaemonActionTarget(e.sess) | |
| 583 | + | target := resolveDaemonActionTargetForAttachment(e.sess, e.ac) | |
| 584 | 584 | if target.tab == nil || target.pane == nil { | |
| 585 | 585 | return errMovePaneInvalid | |
| 586 | 586 | } | |
| @@ -593,7 +593,7 @@ func (e paletteExec) OpenMovePanePicker() error { | |||
| 593 | 593 | } | |
| 594 | 594 | ||
| 595 | 595 | func (e paletteExec) OpenMoveTabPicker() error { | |
| 596 | - | target := resolveDaemonActionTarget(e.sess) | |
| 596 | + | target := resolveDaemonActionTargetForAttachment(e.sess, e.ac) | |
| 597 | 597 | if target.tab == nil { | |
| 598 | 598 | return errMovePaneInvalid | |
| 599 | 599 | } | |
| @@ -672,12 +672,19 @@ func (e paletteExec) RenameSession() error { | |||
| 672 | 672 | } | |
| 673 | 673 | ||
| 674 | 674 | func (e paletteExec) RenameTab() error { | |
| 675 | - | tb := e.sess.activeTab() | |
| 675 | + | tb := e.sess.tabForAttachmentOrActive(e.ac) | |
| 676 | 676 | if tb == nil { | |
| 677 | 677 | return nil | |
| 678 | 678 | } | |
| 679 | 679 | e.sess.mu.Lock() | |
| 680 | - | currentName := tabDisplayName(tb, e.sess.active) | |
| 680 | + | index := 0 | |
| 681 | + | for i, candidate := range e.sess.tabs { | |
| 682 | + | if candidate == tb { | |
| 683 | + | index = i | |
| 684 | + | break | |
| 685 | + | } | |
| 686 | + | } | |
| 687 | + | currentName := tabDisplayName(tb, index) | |
| 681 | 688 | e.sess.mu.Unlock() | |
| 682 | 689 | e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error { | |
| 683 | 690 | return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, target: daemonActionTarget{session: e.sess, tab: tb}, name: name}) | |
| @@ -501,7 +501,7 @@ type paletteExec struct { | |||||
| 501 | 501 | ||||
| 502 | func (e paletteExec) runAction(request daemonActionRequest) error { | 502 | func (e paletteExec) runAction(request daemonActionRequest) error { | ||
| 503 | if request.target.session == nil { | 503 | if request.target.session == nil { | ||
| 504 | - | request.target = resolveDaemonActionTarget(e.sess) | 504 | + | request.target = resolveDaemonActionTargetForAttachment(e.sess, e.ac) |
| 505 | } | 505 | } | ||
| 506 | runner := e.actions | 506 | runner := e.actions | ||
| 507 | if runner == nil { | 507 | if runner == nil { | ||
| @@ -580,7 +580,7 @@ func (e paletteExec) ClosePane() error { | |||||
| 580 | } | 580 | } | ||
| 581 | 581 | ||||
| 582 | func (e paletteExec) OpenMovePanePicker() error { | 582 | func (e paletteExec) OpenMovePanePicker() error { | ||
| 583 | - | target := resolveDaemonActionTarget(e.sess) | 583 | + | target := resolveDaemonActionTargetForAttachment(e.sess, e.ac) |
| 584 | if target.tab == nil || target.pane == nil { | 584 | if target.tab == nil || target.pane == nil { | ||
| 585 | return errMovePaneInvalid | 585 | return errMovePaneInvalid | ||
| 586 | } | 586 | } | ||
| @@ -593,7 +593,7 @@ func (e paletteExec) OpenMovePanePicker() error { | |||||
| 593 | } | 593 | } | ||
| 594 | 594 | ||||
| 595 | func (e paletteExec) OpenMoveTabPicker() error { | 595 | func (e paletteExec) OpenMoveTabPicker() error { | ||
| 596 | - | target := resolveDaemonActionTarget(e.sess) | 596 | + | target := resolveDaemonActionTargetForAttachment(e.sess, e.ac) |
| 597 | if target.tab == nil { | 597 | if target.tab == nil { | ||
| 598 | return errMovePaneInvalid | 598 | return errMovePaneInvalid | ||
| 599 | } | 599 | } | ||
| @@ -672,12 +672,19 @@ func (e paletteExec) RenameSession() error { | |||||
| 672 | } | 672 | } | ||
| 673 | 673 | ||||
| 674 | func (e paletteExec) RenameTab() error { | 674 | func (e paletteExec) RenameTab() error { | ||
| 675 | - | tb := e.sess.activeTab() | 675 | + | tb := e.sess.tabForAttachmentOrActive(e.ac) |
| 676 | if tb == nil { | 676 | if tb == nil { | ||
| 677 | return nil | 677 | return nil | ||
| 678 | } | 678 | } | ||
| 679 | e.sess.mu.Lock() | 679 | e.sess.mu.Lock() | ||
| 680 | - | currentName := tabDisplayName(tb, e.sess.active) | 680 | + | index := 0 |
| 681 | + | for i, candidate := range e.sess.tabs { | |||
| 682 | + | if candidate == tb { | |||
| 683 | + | index = i | |||
| 684 | + | break | |||
| 685 | + | } | |||
| 686 | + | } | |||
| 687 | + | currentName := tabDisplayName(tb, index) | |||
| 681 | e.sess.mu.Unlock() | 688 | e.sess.mu.Unlock() | ||
| 682 | e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error { | 689 | e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error { | ||
| 683 | return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, target: daemonActionTarget{session: e.sess, tab: tb}, name: name}) | 690 | return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, target: daemonActionTarget{session: e.sess, tab: tb}, name: name}) | ||
M internal/usecase/daemon/pane_actions.go +4 -4
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Direction) error { | |
| 13 | - | target := resolveDaemonActionTarget(sess) | |
| 13 | + | target := resolveDaemonActionTargetForAttachment(sess, ac) | |
| 14 | 14 | err := d.splitPaneAt(sess, target.tab, target.pane, dir) | |
| 15 | 15 | if err == nil && ac != nil { | |
| 16 | 16 | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| @@ -131,7 +131,7 @@ func placementContent(placements []layout.Placement, id layout.PaneID) domain.Re | |||
| 131 | 131 | func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} } | |
| 132 | 132 | ||
| 133 | 133 | func (d *Daemon) stackPane(sess *session, ac *attachedClient) error { | |
| 134 | - | target := resolveDaemonActionTarget(sess) | |
| 134 | + | target := resolveDaemonActionTargetForAttachment(sess, ac) | |
| 135 | 135 | err := d.stackPaneAt(sess, target.tab, target.pane) | |
| 136 | 136 | if err == nil && ac != nil { | |
| 137 | 137 | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| @@ -146,7 +146,7 @@ func (d *Daemon) stackPaneAt(sess *session, tb *tab, target *pane) error { | |||
| 146 | 146 | } | |
| 147 | 147 | ||
| 148 | 148 | func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error { | |
| 149 | - | target := resolveDaemonActionTarget(sess) | |
| 149 | + | target := resolveDaemonActionTargetForAttachment(sess, ac) | |
| 150 | 150 | err := d.toggleStackAt(sess, target.tab, target.pane) | |
| 151 | 151 | if err == nil && ac != nil { | |
| 152 | 152 | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| @@ -360,7 +360,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||
| 360 | 360 | } | |
| 361 | 361 | ||
| 362 | 362 | func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction, effect *roleEffectTicket) error { | |
| 363 | - | target := resolveDaemonActionTarget(sess) | |
| 363 | + | target := resolveDaemonActionTargetForAttachment(sess, ac) | |
| 364 | 364 | oldFocus := layout.PaneID("") | |
| 365 | 365 | if target.pane != nil { | |
| 366 | 366 | oldFocus = target.pane.id | |
| @@ -10,7 +10,7 @@ import ( | |||||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Direction) error { | 12 | func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Direction) error { | ||
| 13 | - | target := resolveDaemonActionTarget(sess) | 13 | + | target := resolveDaemonActionTargetForAttachment(sess, ac) |
| 14 | err := d.splitPaneAt(sess, target.tab, target.pane, dir) | 14 | err := d.splitPaneAt(sess, target.tab, target.pane, dir) | ||
| 15 | if err == nil && ac != nil { | 15 | if err == nil && ac != nil { | ||
| 16 | d.invalidateRender(sess, ac, true, "pane_actions.go") | 16 | d.invalidateRender(sess, ac, true, "pane_actions.go") | ||
| @@ -131,7 +131,7 @@ func placementContent(placements []layout.Placement, id layout.PaneID) domain.Re | |||||
| 131 | func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} } | 131 | func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} } | ||
| 132 | 132 | ||||
| 133 | func (d *Daemon) stackPane(sess *session, ac *attachedClient) error { | 133 | func (d *Daemon) stackPane(sess *session, ac *attachedClient) error { | ||
| 134 | - | target := resolveDaemonActionTarget(sess) | 134 | + | target := resolveDaemonActionTargetForAttachment(sess, ac) |
| 135 | err := d.stackPaneAt(sess, target.tab, target.pane) | 135 | err := d.stackPaneAt(sess, target.tab, target.pane) | ||
| 136 | if err == nil && ac != nil { | 136 | if err == nil && ac != nil { | ||
| 137 | d.invalidateRender(sess, ac, true, "pane_actions.go") | 137 | d.invalidateRender(sess, ac, true, "pane_actions.go") | ||
| @@ -146,7 +146,7 @@ func (d *Daemon) stackPaneAt(sess *session, tb *tab, target *pane) error { | |||||
| 146 | } | 146 | } | ||
| 147 | 147 | ||||
| 148 | func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error { | 148 | func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error { | ||
| 149 | - | target := resolveDaemonActionTarget(sess) | 149 | + | target := resolveDaemonActionTargetForAttachment(sess, ac) |
| 150 | err := d.toggleStackAt(sess, target.tab, target.pane) | 150 | err := d.toggleStackAt(sess, target.tab, target.pane) | ||
| 151 | if err == nil && ac != nil { | 151 | if err == nil && ac != nil { | ||
| 152 | d.invalidateRender(sess, ac, true, "pane_actions.go") | 152 | d.invalidateRender(sess, ac, true, "pane_actions.go") | ||
| @@ -360,7 +360,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||||
| 360 | } | 360 | } | ||
| 361 | 361 | ||||
| 362 | func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction, effect *roleEffectTicket) error { | 362 | func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction, effect *roleEffectTicket) error { | ||
| 363 | - | target := resolveDaemonActionTarget(sess) | 363 | + | target := resolveDaemonActionTargetForAttachment(sess, ac) |
| 364 | oldFocus := layout.PaneID("") | 364 | oldFocus := layout.PaneID("") | ||
| 365 | if target.pane != nil { | 365 | if target.pane != nil { | ||
| 366 | oldFocus = target.pane.id | 366 | oldFocus = target.pane.id | ||
M internal/usecase/daemon/proxy_input.go +1 -1
| @@ -227,7 +227,7 @@ func proxiedJumpSearchesOtherSessions(proxied bool) bool { return !proxied } | |||
| 227 | 227 | // but never follows OverflowSessions. The local proxy daemon is the only owner | |
| 228 | 228 | // allowed to change the selected session. | |
| 229 | 229 | func (d *Daemon) focusDirProxied(sess *session, ac *attachedClient, dir layout.Direction) error { | |
| 230 | - | target := resolveDaemonActionTarget(sess) | |
| 230 | + | target := resolveDaemonActionTargetForAttachment(sess, ac) | |
| 231 | 231 | oldFocus := layout.PaneID("") | |
| 232 | 232 | if target.pane != nil { | |
| 233 | 233 | oldFocus = target.pane.id | |
| @@ -227,7 +227,7 @@ func proxiedJumpSearchesOtherSessions(proxied bool) bool { return !proxied } | |||||
| 227 | // but never follows OverflowSessions. The local proxy daemon is the only owner | 227 | // but never follows OverflowSessions. The local proxy daemon is the only owner | ||
| 228 | // allowed to change the selected session. | 228 | // allowed to change the selected session. | ||
| 229 | func (d *Daemon) focusDirProxied(sess *session, ac *attachedClient, dir layout.Direction) error { | 229 | func (d *Daemon) focusDirProxied(sess *session, ac *attachedClient, dir layout.Direction) error { | ||
| 230 | - | target := resolveDaemonActionTarget(sess) | 230 | + | target := resolveDaemonActionTargetForAttachment(sess, ac) |
| 231 | oldFocus := layout.PaneID("") | 231 | oldFocus := layout.PaneID("") | ||
| 232 | if target.pane != nil { | 232 | if target.pane != nil { | ||
| 233 | oldFocus = target.pane.id | 233 | oldFocus = target.pane.id | ||
M internal/usecase/daemon/render_capture.go +13 -4
| @@ -212,12 +212,21 @@ func captureLocalPrimaryRenderState( | |||
| 212 | 212 | return nil, false | |
| 213 | 213 | } | |
| 214 | 214 | sess.mu.Lock() | |
| 215 | - | if sess.client != ac || sess.active < 0 || sess.active >= len(sess.tabs) { | |
| 216 | - | sess.mu.Unlock() | |
| 217 | - | return nil, false | |
| 215 | + | owned := sess.client == ac | |
| 216 | + | if !owned && sess.attachments != nil { | |
| 217 | + | _, owned = sess.attachments[ac] | |
| 218 | 218 | } | |
| 219 | - | tb := sess.tabs[sess.active] | |
| 220 | 219 | sess.mu.Unlock() | |
| 220 | + | if !owned { | |
| 221 | + | return nil, false | |
| 222 | + | } | |
| 223 | + | tb, _ := sess.paneForAttachment(ac) | |
| 224 | + | if tb == nil { | |
| 225 | + | tb = sess.activeTab() | |
| 226 | + | } | |
| 227 | + | if tb == nil { | |
| 228 | + | return nil, false | |
| 229 | + | } | |
| 221 | 230 | ||
| 222 | 231 | scratch := &ac.renderScratch | |
| 223 | 232 | scratch.statusTabs = append(scratch.statusTabs[:0], bars.status.tabs...) | |
| @@ -212,12 +212,21 @@ func captureLocalPrimaryRenderState( | |||||
| 212 | return nil, false | 212 | return nil, false | ||
| 213 | } | 213 | } | ||
| 214 | sess.mu.Lock() | 214 | sess.mu.Lock() | ||
| 215 | - | if sess.client != ac || sess.active < 0 || sess.active >= len(sess.tabs) { | 215 | + | owned := sess.client == ac |
| 216 | - | sess.mu.Unlock() | 216 | + | if !owned && sess.attachments != nil { |
| 217 | - | return nil, false | 217 | + | _, owned = sess.attachments[ac] |
| 218 | } | 218 | } | ||
| 219 | - | tb := sess.tabs[sess.active] | |||
| 220 | sess.mu.Unlock() | 219 | sess.mu.Unlock() | ||
| 220 | + | if !owned { | |||
| 221 | + | return nil, false | |||
| 222 | + | } | |||
| 223 | + | tb, _ := sess.paneForAttachment(ac) | |||
| 224 | + | if tb == nil { | |||
| 225 | + | tb = sess.activeTab() | |||
| 226 | + | } | |||
| 227 | + | if tb == nil { | |||
| 228 | + | return nil, false | |||
| 229 | + | } | |||
| 221 | 230 | ||||
| 222 | scratch := &ac.renderScratch | 231 | scratch := &ac.renderScratch | ||
| 223 | scratch.statusTabs = append(scratch.statusTabs[:0], bars.status.tabs...) | 232 | scratch.statusTabs = append(scratch.statusTabs[:0], bars.status.tabs...) | ||
M internal/usecase/daemon/session.go +4 -0
| @@ -717,6 +717,7 @@ func (s *session) detachIfCurrent(ac *attachedClient) bool { | |||
| 717 | 717 | defer s.mu.Unlock() | |
| 718 | 718 | if s.client == ac { | |
| 719 | 719 | s.client = nil | |
| 720 | + | s.unregisterAttachmentLocked(ac) | |
| 720 | 721 | ac.setSession(nil) | |
| 721 | 722 | return true | |
| 722 | 723 | } | |
| @@ -749,6 +750,7 @@ func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, exp | |||
| 749 | 750 | } | |
| 750 | 751 | if current { | |
| 751 | 752 | sess.client = nil | |
| 753 | + | sess.unregisterAttachmentLocked(ac) | |
| 752 | 754 | ac.roleGeneration.Add(1) | |
| 753 | 755 | ac.setSession(nil) | |
| 754 | 756 | ac.invalidateFrozenRoleCapability() | |
| @@ -779,6 +781,7 @@ func (d *Daemon) detachProxyIfCurrentTransport(p *proxySession, ac *attachedClie | |||
| 779 | 781 | } | |
| 780 | 782 | if current { | |
| 781 | 783 | p.client = nil | |
| 784 | + | unregisterAttachmentSessionLocked(attachmentSession(p), ac) | |
| 782 | 785 | ac.roleGeneration.Add(1) | |
| 783 | 786 | ac.setSession(nil) | |
| 784 | 787 | ac.invalidateFrozenRoleCapability() | |
| @@ -829,6 +832,7 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func() | |||
| 829 | 832 | current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req) | |
| 830 | 833 | if current { | |
| 831 | 834 | core.client = nil | |
| 835 | + | unregisterAttachmentSessionLocked(token.sess, token.ac) | |
| 832 | 836 | token.ac.roleGeneration.Add(1) | |
| 833 | 837 | token.ac.setSession(nil) | |
| 834 | 838 | token.ac.invalidateFrozenRoleCapability() | |
| @@ -717,6 +717,7 @@ func (s *session) detachIfCurrent(ac *attachedClient) bool { | |||||
| 717 | defer s.mu.Unlock() | 717 | defer s.mu.Unlock() | ||
| 718 | if s.client == ac { | 718 | if s.client == ac { | ||
| 719 | s.client = nil | 719 | s.client = nil | ||
| 720 | + | s.unregisterAttachmentLocked(ac) | |||
| 720 | ac.setSession(nil) | 721 | ac.setSession(nil) | ||
| 721 | return true | 722 | return true | ||
| 722 | } | 723 | } | ||
| @@ -749,6 +750,7 @@ func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, exp | |||||
| 749 | } | 750 | } | ||
| 750 | if current { | 751 | if current { | ||
| 751 | sess.client = nil | 752 | sess.client = nil | ||
| 753 | + | sess.unregisterAttachmentLocked(ac) | |||
| 752 | ac.roleGeneration.Add(1) | 754 | ac.roleGeneration.Add(1) | ||
| 753 | ac.setSession(nil) | 755 | ac.setSession(nil) | ||
| 754 | ac.invalidateFrozenRoleCapability() | 756 | ac.invalidateFrozenRoleCapability() | ||
| @@ -779,6 +781,7 @@ func (d *Daemon) detachProxyIfCurrentTransport(p *proxySession, ac *attachedClie | |||||
| 779 | } | 781 | } | ||
| 780 | if current { | 782 | if current { | ||
| 781 | p.client = nil | 783 | p.client = nil | ||
| 784 | + | unregisterAttachmentSessionLocked(attachmentSession(p), ac) | |||
| 782 | ac.roleGeneration.Add(1) | 785 | ac.roleGeneration.Add(1) | ||
| 783 | ac.setSession(nil) | 786 | ac.setSession(nil) | ||
| 784 | ac.invalidateFrozenRoleCapability() | 787 | ac.invalidateFrozenRoleCapability() | ||
| @@ -829,6 +832,7 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func() | |||||
| 829 | current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req) | 832 | current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req) | ||
| 830 | if current { | 833 | if current { | ||
| 831 | core.client = nil | 834 | core.client = nil | ||
| 835 | + | unregisterAttachmentSessionLocked(token.sess, token.ac) | |||
| 832 | token.ac.roleGeneration.Add(1) | 836 | token.ac.roleGeneration.Add(1) | ||
| 833 | token.ac.setSession(nil) | 837 | token.ac.setSession(nil) | ||
| 834 | token.ac.invalidateFrozenRoleCapability() | 838 | token.ac.invalidateFrozenRoleCapability() | ||
M internal/usecase/daemon/session_core.go +16 -10
| @@ -24,16 +24,22 @@ type attachmentSession interface { | |||
| 24 | 24 | type sessionCore struct { | |
| 25 | 25 | mu sync.Mutex | |
| 26 | 26 | ||
| 27 | - | id domain.SessionID | |
| 28 | - | name string | |
| 29 | - | ephemeral bool | |
| 30 | - | caps sessionCapabilities | |
| 31 | - | client *attachedClient | |
| 32 | - | snatched map[*attachedClient]struct{} | |
| 33 | - | createdAt int64 | |
| 34 | - | incarnation domain.IncarnationID | |
| 35 | - | mruAt atomic.Uint64 | |
| 36 | - | coordinator atomic.Pointer[renderCoordinator] | |
| 27 | + | id domain.SessionID | |
| 28 | + | name string | |
| 29 | + | ephemeral bool | |
| 30 | + | caps sessionCapabilities | |
| 31 | + | // attachments is the session-owned membership registry. Role transition | |
| 32 | + | // metadata below is separate from membership; admission and view code use | |
| 33 | + | // this collection as their membership source. | |
| 34 | + | attachments map[*attachedClient]struct{} | |
| 35 | + | attachmentOrder map[*attachedClient]uint64 | |
| 36 | + | nextAttachmentID uint64 | |
| 37 | + | client *attachedClient | |
| 38 | + | snatched map[*attachedClient]struct{} | |
| 39 | + | createdAt int64 | |
| 40 | + | incarnation domain.IncarnationID | |
| 41 | + | mruAt atomic.Uint64 | |
| 42 | + | coordinator atomic.Pointer[renderCoordinator] | |
| 37 | 43 | } | |
| 38 | 44 | ||
| 39 | 45 | func (s *session) core() *sessionCore { | |
| @@ -24,16 +24,22 @@ type attachmentSession interface { | |||||
| 24 | type sessionCore struct { | 24 | type sessionCore struct { | ||
| 25 | mu sync.Mutex | 25 | mu sync.Mutex | ||
| 26 | 26 | ||||
| 27 | - | id domain.SessionID | 27 | + | id domain.SessionID |
| 28 | - | name string | 28 | + | name string |
| 29 | - | ephemeral bool | 29 | + | ephemeral bool |
| 30 | - | caps sessionCapabilities | 30 | + | caps sessionCapabilities |
| 31 | - | client *attachedClient | 31 | + | // attachments is the session-owned membership registry. Role transition |
| 32 | - | snatched map[*attachedClient]struct{} | 32 | + | // metadata below is separate from membership; admission and view code use |
| 33 | - | createdAt int64 | 33 | + | // this collection as their membership source. |
| 34 | - | incarnation domain.IncarnationID | 34 | + | attachments map[*attachedClient]struct{} |
| 35 | - | mruAt atomic.Uint64 | 35 | + | attachmentOrder map[*attachedClient]uint64 |
| 36 | - | coordinator atomic.Pointer[renderCoordinator] | 36 | + | nextAttachmentID uint64 |
| 37 | + | client *attachedClient | |||
| 38 | + | snatched map[*attachedClient]struct{} | |||
| 39 | + | createdAt int64 | |||
| 40 | + | incarnation domain.IncarnationID | |||
| 41 | + | mruAt atomic.Uint64 | |||
| 42 | + | coordinator atomic.Pointer[renderCoordinator] | |||
| 37 | } | 43 | } | ||
| 38 | 44 | ||||
| 39 | func (s *session) core() *sessionCore { | 45 | func (s *session) core() *sessionCore { | ||
M internal/usecase/daemon/session_view.go +5 -2
| @@ -53,7 +53,6 @@ func (s *session) snapshotView(opts viewOptions) sessionView { | |||
| 53 | 53 | opts.tabDetails = true | |
| 54 | 54 | } | |
| 55 | 55 | s.mu.Lock() | |
| 56 | - | defer s.mu.Unlock() | |
| 57 | 56 | view := sessionView{ | |
| 58 | 57 | id: s.id, | |
| 59 | 58 | incarnation: s.incarnation, | |
| @@ -62,7 +61,7 @@ func (s *session) snapshotView(opts viewOptions) sessionView { | |||
| 62 | 61 | createdAt: s.createdAt, | |
| 63 | 62 | active: s.active, | |
| 64 | 63 | mruAt: s.mruAt.Load(), | |
| 65 | - | attached: s.client != nil, | |
| 64 | + | attached: len(s.attachments) != 0 || s.client != nil, | |
| 66 | 65 | tabCount: len(s.tabs), | |
| 67 | 66 | cannotAcceptMoves: s.capabilities().cannotAcceptMoves, | |
| 68 | 67 | } | |
| @@ -90,6 +89,10 @@ func (s *session) snapshotView(opts viewOptions) sessionView { | |||
| 90 | 89 | } | |
| 91 | 90 | view.tabs = append(view.tabs, entry) | |
| 92 | 91 | } | |
| 92 | + | s.mu.Unlock() | |
| 93 | + | if !view.attached { | |
| 94 | + | view.attached = len(s.snapshotAttachmentViews()) != 0 | |
| 95 | + | } | |
| 93 | 96 | return view | |
| 94 | 97 | } | |
| 95 | 98 | ||
| @@ -53,7 +53,6 @@ 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 | - | defer s.mu.Unlock() | |||
| 57 | view := sessionView{ | 56 | view := sessionView{ | ||
| 58 | id: s.id, | 57 | id: s.id, | ||
| 59 | incarnation: s.incarnation, | 58 | incarnation: s.incarnation, | ||
| @@ -62,7 +61,7 @@ func (s *session) snapshotView(opts viewOptions) sessionView { | |||||
| 62 | createdAt: s.createdAt, | 61 | createdAt: s.createdAt, | ||
| 63 | active: s.active, | 62 | active: s.active, | ||
| 64 | mruAt: s.mruAt.Load(), | 63 | mruAt: s.mruAt.Load(), | ||
| 65 | - | attached: s.client != nil, | 64 | + | attached: len(s.attachments) != 0 || s.client != nil, |
| 66 | tabCount: len(s.tabs), | 65 | tabCount: len(s.tabs), | ||
| 67 | cannotAcceptMoves: s.capabilities().cannotAcceptMoves, | 66 | cannotAcceptMoves: s.capabilities().cannotAcceptMoves, | ||
| 68 | } | 67 | } | ||
| @@ -90,6 +89,10 @@ func (s *session) snapshotView(opts viewOptions) sessionView { | |||||
| 90 | } | 89 | } | ||
| 91 | view.tabs = append(view.tabs, entry) | 90 | view.tabs = append(view.tabs, entry) | ||
| 92 | } | 91 | } | ||
| 92 | + | s.mu.Unlock() | |||
| 93 | + | if !view.attached { | |||
| 94 | + | view.attached = len(s.snapshotAttachmentViews()) != 0 | |||
| 95 | + | } | |||
| 93 | return view | 96 | return view | ||
| 94 | } | 97 | } | ||
| 95 | 98 | ||||
M internal/usecase/daemon/snatched_cleanup.go +3 -0
| @@ -24,6 +24,9 @@ func (d *Daemon) unrouteSnatchedAttachment(token attachmentRoleToken, terminal b | |||
| 24 | 24 | token.ac.transportSnapshotCurrent(token.transport) | |
| 25 | 25 | if current { | |
| 26 | 26 | delete(core.snatched, token.ac) | |
| 27 | + | if terminal { | |
| 28 | + | core.unregisterAttachmentLocked(token.ac) | |
| 29 | + | } | |
| 27 | 30 | token.ac.roleGeneration.Add(1) | |
| 28 | 31 | token.ac.invalidateFrozenRoleCapability() | |
| 29 | 32 | } | |
| @@ -24,6 +24,9 @@ func (d *Daemon) unrouteSnatchedAttachment(token attachmentRoleToken, terminal b | |||||
| 24 | token.ac.transportSnapshotCurrent(token.transport) | 24 | token.ac.transportSnapshotCurrent(token.transport) | ||
| 25 | if current { | 25 | if current { | ||
| 26 | delete(core.snatched, token.ac) | 26 | delete(core.snatched, token.ac) | ||
| 27 | + | if terminal { | |||
| 28 | + | core.unregisterAttachmentLocked(token.ac) | |||
| 29 | + | } | |||
| 27 | token.ac.roleGeneration.Add(1) | 30 | token.ac.roleGeneration.Add(1) | ||
| 28 | token.ac.invalidateFrozenRoleCapability() | 31 | token.ac.invalidateFrozenRoleCapability() | ||
| 29 | } | 32 | } | ||