1package daemon
2
3import (
4 "context"
5 "errors"
6 "os"
7
8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"
10)
11
12// movePanePostcommitPlan is captured while the move's architecture and owner
13// fences still protect publication. It contains only immutable pointers,
14// generations, and exact attachment tokens needed by fallible follow-up work.
15func firstMovePane(panes []*pane) *pane {
16 if len(panes) == 0 {
17 return nil
18 }
19 return panes[0]
20}
21
22type movePanePostcommitPlan struct {
23 source *session
24 destination *session
25 sourceTab *tab
26 destinationTab *tab
27 movedPane *pane
28 movedPanes []*pane
29 sourceAttachments []*attachedClient
30 syncCleanup syncTimerCleanup
31 frozenEffects frozenAttachmentEffectGates
32 effectsFrozen bool
33 unlockDispatch func()
34 reservation *moveLifecycleReservation
35 oldTabCancel context.CancelFunc
36
37 sourceTabRemoved bool
38 sourceEmpty bool
39
40 retiredParked []parkedAttachmentRetirement
41 retiredAttachments []detachedAttachmentSnapshot
42 sourceMetadata domain.CatalogueMetadataUpdate
43 sourceMetadataValid bool
44 destinationMetadata domain.CatalogueMetadataUpdate
45 destinationMetadataValid bool
46}
47
48// execute runs only after every resize-owner fence and architecture lock is
49// released. Generation-bound tokens make stale effects drop rather than route
50// back to the retired source.
51func (p movePanePostcommitPlan) execute(d *Daemon) {
52 if p.oldTabCancel != nil {
53 p.oldTabCancel()
54 } else if p.sourceTabRemoved && p.sourceTab != nil && p.sourceTab.cancel != nil {
55 p.sourceTab.cancel()
56 }
57 panes := p.movedPanes
58 if len(panes) == 0 && p.movedPane != nil {
59 panes = []*pane{p.movedPane}
60 }
61 for _, cleanupClient := range p.sourceAttachments {
62 for _, movedPane := range panes {
63 if cleanupClient.overlays != nil {
64 cleanupClient.overlays.clearCopyModeForPane(movedPane)
65 }
66 cleanupClient.pruneCaptureFrames(movedPane)
67 }
68 }
69
70 // Snapshot dirtiness is admitted while lifecycle reservations still pin
71 // both sessions. Repository publication remains outside every lock below.
72 markSnapshotDirty(p.destination)
73 if !p.sourceEmpty {
74 markSnapshotDirty(p.source)
75 }
76 p.syncCleanup.finish()
77 if p.effectsFrozen {
78 p.frozenEffects.unfreeze()
79 }
80 p.unlockDispatch()
81 p.reservation.Release()
82
83 // Repair attachment-local stable targets after shared topology publication
84 // and after the move dispatch locks have been released.
85 p.source.repairAttachmentViews()
86 if p.destination != p.source {
87 p.destination.repairAttachmentViews()
88 }
89
90 if !moveSessionRetired(d, p.source) {
91 sourceLayoutTab := p.sourceTab
92 if p.sourceTabRemoved {
93 sourceLayoutTab = p.source.firstTab()
94 }
95 if sourceLayoutTab != nil && sourceLayoutTab != p.destinationTab {
96 d.applyTabLayout(p.source, sourceLayoutTab)
97 }
98 }
99 d.applyTabLayout(p.destination, p.destinationTab)
100 if p.sourceMetadataValid && !p.sourceEmpty {
101 d.markCatalogueDirty(p.sourceMetadata)
102 }
103 if p.destinationMetadataValid {
104 d.markCatalogueDirty(p.destinationMetadata)
105 }
106 for _, ac := range p.destination.snapshotAttachments() {
107 d.invalidateRender(p.destination, ac, true, "move.go")
108 }
109 if !p.sourceEmpty && p.source != p.destination {
110 for _, ac := range p.source.snapshotAttachments() {
111 d.invalidateRender(p.source, ac, true, "move.go")
112 }
113 }
114 if p.sourceEmpty {
115 retireEmptySessionAfterMove(d, p.source)
116 }
117 for _, attachment := range p.retiredAttachments {
118 d.unregisterPreview(attachment.ac)
119 attachment.ac.clearCaptureFrames()
120 d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled)
121 }
122 for _, retirement := range p.retiredParked {
123 if retirement.parked == nil || retirement.parked.ac == nil {
124 continue
125 }
126 d.unregisterPreview(retirement.parked.ac)
127 retirement.parked.ac.clearCaptureFrames()
128 }
129 d.finishParkedAttachmentRetirements(p.retiredParked)
130 if p.sourceEmpty && p.sourceMetadataValid && d.persistEnabled {
131 if err := d.beginSnapshotPurge(p.source.name, p.source.incarnation); err == nil {
132 if err := d.finishSnapshotPurge(d.serveCtx, p.source.name, p.source.incarnation); err != nil {
133 d.log.Warn("moving final pane source purge failed", "err", err, "session", p.source.name)
134 }
135 }
136 }
137}
138
139func moveSessionRetired(d *Daemon, sess *session) bool {
140 if d == nil || sess == nil {
141 return true
142 }
143 d.mu.Lock()
144 _, live := d.sessions[sess.id]
145 d.mu.Unlock()
146 return !live
147}
148
149// retireEmptySessionAfterMove completes only source-owned lifecycle work. The
150// moved pane has already been removed from source.tabs, so stopInMemoryLifecycle
151// cannot cancel or close the transferred PTY.
152func retireEmptySessionAfterMove(d *Daemon, sess *session) {
153 if d == nil || sess == nil {
154 return
155 }
156 sess.stopInMemoryLifecycle()
157 if coordinator := sess.renderCoordinator(); coordinator != nil {
158 coordinator.beginSessionTeardown().finish()
159 coordinator.waitForTimerWorkers()
160 }
161 sess.mu.Lock()
162 clipFiles := append([]string(nil), sess.clipFiles...)
163 sess.clipFiles = nil
164 sess.mu.Unlock()
165 for _, path := range clipFiles {
166 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
167 d.log.Warn("removing clipboard temp file after move failed", "err", err, "path", path)
168 }
169 }
170}