detached d318448

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
196196 // generations, and currentSession while all role gates remain frozen.
197197 func publishAttachmentOwnershipLocked(publication *attachmentPublication) {
198198 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+ }
199207 if req.targetRole == attachmentActive && publication.old != nil && publication.old != req.next {
200208 publication.displacedTransport = publication.old.transportSnapshot()
201209 // A proxied daemon attachment has no local snatched UI and must never
@@ -214,6 +222,7 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) {
214222 if publication.old != nil && publication.old != req.next {
215223 if publication.terminalDisplaced {
216224 delete(req.target.core().snatched, publication.old)
225+ req.target.core().unregisterAttachmentLocked(publication.old)
217226 } else {
218227 addSnatchedLocked(req.target, publication.old)
219228 }
@@ -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.req198 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 never209 // 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 {
8383 initialSnatchedAttempt *initialSnatchedPanelAttempt
8484 size domain.Size
8585 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
9398 // previousSession is guarded independently. It is retained through temporary
9499 // setSession(nil) hand-offs and cleared only on terminal teardown.
95100 previousSession Guarded[attachmentSession]
@@ -83,13 +83,18 @@ type attachedClient struct {
83 initialSnatchedAttempt *initialSnatchedPanelAttempt83 initialSnatchedAttempt *initialSnatchedPanelAttempt
84 size domain.Size84 size domain.Size
85 keys *keys.Router85 keys *keys.Router
86- sess Guarded[attachmentSession]86+ // view is attachment-local navigation state. It is never inferred from a
87- mouseScan mouse.Scanner87+ // session-wide active tab, so multiple attachments can observe different
88- themeMu sync.Mutex88+ // tabs and panes without changing shared session ownership.
89- clientTheme themeui.Theme89+ viewMu sync.Mutex
90- appliedTheme appliedTheme90+ view attachmentView
91- lastCursor cursorOut91+ sess Guarded[attachmentSession]
92- renderStages renderStageHooks // optional render and handoff observability hooks92+ 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 temporary98 // 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
143143 }
144144
145145 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)
147151 if tb == nil {
148152 return
149153 }
@@ -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 return152 return
149 }153 }
M internal/usecase/daemon/control.go +5 -1
@@ -269,10 +269,14 @@ type daemonActionRunner interface {
269269 }
270270
271271 func resolveDaemonActionTarget(sess *session) daemonActionTarget {
272+ return resolveDaemonActionTargetForAttachment(sess, nil)
273+}
274+
275+func resolveDaemonActionTargetForAttachment(sess *session, ac *attachedClient) daemonActionTarget {
272276 if sess == nil {
273277 return daemonActionTarget{}
274278 }
275- tb := sess.activeTab()
279+ tb := sess.tabForAttachmentOrActive(ac)
276280 if tb == nil {
277281 return daemonActionTarget{session: sess}
278282 }
@@ -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
179179 if !ok {
180180 return true
181181 }
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) {
183183 mode.StartCharacterSelection(pointer.press)
184184 mode.ExtendCharacterSelection(mapped.pos)
185185 }, 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 true180 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) {
6363 d.invalidateRender(sess, ac, true, "copymode.go")
6464 }
6565 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
66- tb := sess.activeTab()
66+ tb := sess.tabForAttachmentOrActive(ac)
6767 if tb == nil {
6868 return
6969 }
@@ -114,7 +114,7 @@ func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *
114114 if d.beforeCopyModeRevalidate != nil {
115115 d.beforeCopyModeRevalidate()
116116 }
117- valid := sess.activeTab() == tb
117+ valid := sess.tabForAttachmentOrActive(ac) == tb
118118 if valid {
119119 tb.mu.Lock()
120120 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 return68 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() == tb117+ 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 {
222222 if d == nil || sess == nil {
223223 return domain.UserErr(domain.NoticeFloatingSpawn, "couldn't open floating pane: no active session", nil)
224224 }
225- tb := sess.activeTab()
225+ tb := sess.tabForAttachmentOrActive(ac)
226226 if tb == nil {
227227 return domain.UserErr(domain.NoticeFloatingSpawn, "couldn't open floating pane: no active tab", layout.ErrNotFound)
228228 }
@@ -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) {
9393 invalidateRejectedLeftPointer(rt, ev)
9494 return
9595 }
96- tb := sess.activeTab()
96+ tb := sess.tabForAttachmentOrActive(ac)
9797 if tb == nil {
9898 invalidateRejectedLeftPointer(rt, ev)
9999 return
@@ -364,7 +364,7 @@ func (h daemonKeyHandler) Forward(data []byte) {
364364 if owned {
365365 defer effect.End()
366366 }
367- tb := sess.activeTab()
367+ tb := sess.tabForAttachmentOrActive(h.ac)
368368 if tb == nil {
369369 return
370370 }
@@ -383,7 +383,7 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
383383 defer effect.End()
384384 }
385385 runAction := func(request daemonActionRequest) {
386- request.target = resolveDaemonActionTarget(sess)
386+ request.target = resolveDaemonActionTargetForAttachment(sess, h.ac)
387387 runner := h.actions
388388 if runner == nil {
389389 runner = daemonActions{d: h.d}
@@ -408,8 +408,8 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
408408 h.d.enterPalette(sess, h.ac)
409409 case keys.ActionJumpAttention:
410410 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)
413413 h.d.invalidateRender(sess, h.ac, true, "input.go")
414414 }
415415 return
@@ -457,8 +457,8 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
457457 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
458458 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
459459 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)
462462 h.d.invalidateRender(sess, h.ac, true, "input.go")
463463 }
464464 }
@@ -93,7 +93,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
93 invalidateRejectedLeftPointer(rt, ev)93 invalidateRejectedLeftPointer(rt, ev)
94 return94 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 return99 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 return369 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.actions387 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 return415 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
153153 for _, ac := range retirement.clients {
154154 retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: ac.transportSnapshot()})
155155 delete(sess.snatched, ac)
156+ sess.unregisterAttachmentLocked(ac)
156157 ac.roleGeneration.Add(1)
157158 ac.setSession(nil)
158159 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 {
501501
502502 func (e paletteExec) runAction(request daemonActionRequest) error {
503503 if request.target.session == nil {
504- request.target = resolveDaemonActionTarget(e.sess)
504+ request.target = resolveDaemonActionTargetForAttachment(e.sess, e.ac)
505505 }
506506 runner := e.actions
507507 if runner == nil {
@@ -580,7 +580,7 @@ func (e paletteExec) ClosePane() error {
580580 }
581581
582582 func (e paletteExec) OpenMovePanePicker() error {
583- target := resolveDaemonActionTarget(e.sess)
583+ target := resolveDaemonActionTargetForAttachment(e.sess, e.ac)
584584 if target.tab == nil || target.pane == nil {
585585 return errMovePaneInvalid
586586 }
@@ -593,7 +593,7 @@ func (e paletteExec) OpenMovePanePicker() error {
593593 }
594594
595595 func (e paletteExec) OpenMoveTabPicker() error {
596- target := resolveDaemonActionTarget(e.sess)
596+ target := resolveDaemonActionTargetForAttachment(e.sess, e.ac)
597597 if target.tab == nil {
598598 return errMovePaneInvalid
599599 }
@@ -672,12 +672,19 @@ func (e paletteExec) RenameSession() error {
672672 }
673673
674674 func (e paletteExec) RenameTab() error {
675- tb := e.sess.activeTab()
675+ tb := e.sess.tabForAttachmentOrActive(e.ac)
676676 if tb == nil {
677677 return nil
678678 }
679679 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)
681688 e.sess.mu.Unlock()
682689 e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error {
683690 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.actions506 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 errMovePaneInvalid585 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 errMovePaneInvalid598 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 nil677 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 (
1010 )
1111
1212 func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Direction) error {
13- target := resolveDaemonActionTarget(sess)
13+ target := resolveDaemonActionTargetForAttachment(sess, ac)
1414 err := d.splitPaneAt(sess, target.tab, target.pane, dir)
1515 if err == nil && ac != nil {
1616 d.invalidateRender(sess, ac, true, "pane_actions.go")
@@ -131,7 +131,7 @@ func placementContent(placements []layout.Placement, id layout.PaneID) domain.Re
131131 func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} }
132132
133133 func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {
134- target := resolveDaemonActionTarget(sess)
134+ target := resolveDaemonActionTargetForAttachment(sess, ac)
135135 err := d.stackPaneAt(sess, target.tab, target.pane)
136136 if err == nil && ac != nil {
137137 d.invalidateRender(sess, ac, true, "pane_actions.go")
@@ -146,7 +146,7 @@ func (d *Daemon) stackPaneAt(sess *session, tb *tab, target *pane) error {
146146 }
147147
148148 func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
149- target := resolveDaemonActionTarget(sess)
149+ target := resolveDaemonActionTargetForAttachment(sess, ac)
150150 err := d.toggleStackAt(sess, target.tab, target.pane)
151151 if err == nil && ac != nil {
152152 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
360360 }
361361
362362 func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction, effect *roleEffectTicket) error {
363- target := resolveDaemonActionTarget(sess)
363+ target := resolveDaemonActionTargetForAttachment(sess, ac)
364364 oldFocus := layout.PaneID("")
365365 if target.pane != nil {
366366 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.id366 oldFocus = target.pane.id
M internal/usecase/daemon/proxy_input.go +1 -1
@@ -227,7 +227,7 @@ func proxiedJumpSearchesOtherSessions(proxied bool) bool { return !proxied }
227227 // but never follows OverflowSessions. The local proxy daemon is the only owner
228228 // allowed to change the selected session.
229229 func (d *Daemon) focusDirProxied(sess *session, ac *attachedClient, dir layout.Direction) error {
230- target := resolveDaemonActionTarget(sess)
230+ target := resolveDaemonActionTargetForAttachment(sess, ac)
231231 oldFocus := layout.PaneID("")
232232 if target.pane != nil {
233233 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 owner227 // 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.id233 oldFocus = target.pane.id
M internal/usecase/daemon/render_capture.go +13 -4
@@ -212,12 +212,21 @@ func captureLocalPrimaryRenderState(
212212 return nil, false
213213 }
214214 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]
218218 }
219- tb := sess.tabs[sess.active]
220219 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+ }
221230
222231 scratch := &ac.renderScratch
223232 scratch.statusTabs = append(scratch.statusTabs[:0], bars.status.tabs...)
@@ -212,12 +212,21 @@ func captureLocalPrimaryRenderState(
212 return nil, false212 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, false217+ _, 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.renderScratch231 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 {
717717 defer s.mu.Unlock()
718718 if s.client == ac {
719719 s.client = nil
720+ s.unregisterAttachmentLocked(ac)
720721 ac.setSession(nil)
721722 return true
722723 }
@@ -749,6 +750,7 @@ func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, exp
749750 }
750751 if current {
751752 sess.client = nil
753+ sess.unregisterAttachmentLocked(ac)
752754 ac.roleGeneration.Add(1)
753755 ac.setSession(nil)
754756 ac.invalidateFrozenRoleCapability()
@@ -779,6 +781,7 @@ func (d *Daemon) detachProxyIfCurrentTransport(p *proxySession, ac *attachedClie
779781 }
780782 if current {
781783 p.client = nil
784+ unregisterAttachmentSessionLocked(attachmentSession(p), ac)
782785 ac.roleGeneration.Add(1)
783786 ac.setSession(nil)
784787 ac.invalidateFrozenRoleCapability()
@@ -829,6 +832,7 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func()
829832 current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
830833 if current {
831834 core.client = nil
835+ unregisterAttachmentSessionLocked(token.sess, token.ac)
832836 token.ac.roleGeneration.Add(1)
833837 token.ac.setSession(nil)
834838 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 = nil719 s.client = nil
720+ s.unregisterAttachmentLocked(ac)
720 ac.setSession(nil)721 ac.setSession(nil)
721 return true722 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 = nil752 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 = nil783 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 = nil834 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 {
2424 type sessionCore struct {
2525 mu sync.Mutex
2626
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]
3743 }
3844
3945 func (s *session) core() *sessionCore {
@@ -24,16 +24,22 @@ type attachmentSession interface {
24 type sessionCore struct {24 type sessionCore struct {
25 mu sync.Mutex25 mu sync.Mutex
26 26
27- id domain.SessionID27+ id domain.SessionID
28- name string28+ name string
29- ephemeral bool29+ ephemeral bool
30- caps sessionCapabilities30+ caps sessionCapabilities
31- client *attachedClient31+ // 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 int6433+ // this collection as their membership source.
34- incarnation domain.IncarnationID34+ attachments map[*attachedClient]struct{}
35- mruAt atomic.Uint6435+ 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 {
5353 opts.tabDetails = true
5454 }
5555 s.mu.Lock()
56- defer s.mu.Unlock()
5756 view := sessionView{
5857 id: s.id,
5958 incarnation: s.incarnation,
@@ -62,7 +61,7 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
6261 createdAt: s.createdAt,
6362 active: s.active,
6463 mruAt: s.mruAt.Load(),
65- attached: s.client != nil,
64+ attached: len(s.attachments) != 0 || s.client != nil,
6665 tabCount: len(s.tabs),
6766 cannotAcceptMoves: s.capabilities().cannotAcceptMoves,
6867 }
@@ -90,6 +89,10 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
9089 }
9190 view.tabs = append(view.tabs, entry)
9291 }
92+ s.mu.Unlock()
93+ if !view.attached {
94+ view.attached = len(s.snapshotAttachmentViews()) != 0
95+ }
9396 return view
9497 }
9598
@@ -53,7 +53,6 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
53 opts.tabDetails = true53 opts.tabDetails = true
54 }54 }
55 s.mu.Lock()55 s.mu.Lock()
56- 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 view96 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
2424 token.ac.transportSnapshotCurrent(token.transport)
2525 if current {
2626 delete(core.snatched, token.ac)
27+ if terminal {
28+ core.unregisterAttachmentLocked(token.ac)
29+ }
2730 token.ac.roleGeneration.Add(1)
2831 token.ac.invalidateFrozenRoleCapability()
2932 }
@@ -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 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close