v0.7.0 4c16dd8

move_postcommit.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"errors"
6	"os"
7
8	"github.com/bnema/vev/internal/domain"
9	"github.com/bnema/vev/internal/protocol"
10)
11
12// movePostcommitPlan 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 movePostcommitPlan struct {
23	followResult      attachmentTransitionResult
24	afterFollow       func(*attachmentEffect)
25	source            *session
26	destination       *session
27	sourceName        string
28	destinationName   string
29	sourceTab         *tab
30	destinationTab    *tab
31	movedPane         *pane
32	movedPanes        []*pane
33	operation         string
34	sourceAttachments []*attachedClient
35	syncCleanup       syncTimerCleanup
36	frozenEffects     attachmentTransitionGuard
37	effectsFrozen     bool
38	unlockDispatch    func()
39	reservation       *moveLifecycleReservation
40	oldTabCancel      context.CancelFunc
41
42	sourceTabRemoved bool
43	sourceEmpty      bool
44
45	retiredParked            []parkedAttachmentRetirement
46	retiredAttachments       []detachedAttachmentSnapshot
47	sourceMetadata           domain.CatalogueMetadataUpdate
48	sourceMetadataValid      bool
49	destinationMetadata      domain.CatalogueMetadataUpdate
50	destinationMetadataValid bool
51}
52
53// execute runs only after every resize-owner fence and architecture lock is
54// released. Generation-bound tokens make stale effects drop rather than route
55// back to the retired source.
56func (p movePostcommitPlan) execute(d *Daemon) {
57	if p.oldTabCancel != nil {
58		p.oldTabCancel()
59	} else if p.sourceTabRemoved && p.sourceTab != nil && p.sourceTab.cancel != nil {
60		p.sourceTab.cancel()
61	}
62	panes := p.movedPanes
63	if len(panes) == 0 && p.movedPane != nil {
64		panes = []*pane{p.movedPane}
65	}
66	for _, cleanupClient := range p.sourceAttachments {
67		for _, movedPane := range panes {
68			if cleanupClient.overlays != nil {
69				cleanupClient.overlays.clearCopyModeForPane(movedPane)
70			}
71			cleanupClient.pruneCaptureFrames(movedPane)
72		}
73	}
74
75	// Snapshot dirtiness is admitted while lifecycle reservations still pin
76	// both sessions. Repository publication remains outside every lock below.
77	markSnapshotDirty(p.destination)
78	if !p.sourceEmpty {
79		markSnapshotDirty(p.source)
80	}
81	p.syncCleanup.finish()
82	if p.effectsFrozen {
83		p.frozenEffects.unfreeze()
84	}
85	p.unlockDispatch()
86	p.reservation.Release()
87	if p.followResult.published.ac != nil {
88		result, err := d.finishAttachmentTransition(attachmentTransitionRequest{ready: true}, p.followResult)
89		if err != nil {
90			d.log.Warn("composite move follow route identity failed",
91				"err", err, "operation", p.operation,
92				"source_session", p.sourceName, "destination_session", p.destinationName)
93		} else {
94			d.deferAttachmentTransitionCleanups(result)
95			d.firstPaintForTransition(result.published)
96			if p.afterFollow != nil {
97				if d.beforeMoveFollowCompletion != nil {
98					d.beforeMoveFollowCompletion(result.published)
99				}
100				// The committing effect ended so the transaction could freeze
101				// admission. Report completion from a fresh effect on the exact
102				// capability the follow published on the destination.
103				effect, admitted := result.published.ac.beginAttachmentEffect(result.published)
104				if !admitted {
105					// The exact published capability stopped being admissible
106					// between the follow's identity send and this completion. That
107					// is either a concurrent transition superseding the capability
108					// while the attachment stays live, or the attachment being torn
109					// down. Re-admit only the attachment's current capability so the
110					// pending client action is completed exactly once through the
111					// existing admission seam. A detached attachment admits nothing,
112					// so its teardown resolves the pending action; no stale
113					// capability or retired transport is reused.
114					_, effect, admitted = admitCurrentAttachmentCapabilityEffect(result.published)
115				}
116				if admitted {
117					p.afterFollow(effect)
118					effect.End()
119				} else {
120					d.log.Warn("composite move follow completion not admitted",
121						"operation", p.operation, "destination_session", p.destinationName)
122				}
123			}
124		}
125	}
126	attrs := []any{
127		"operation", p.operation,
128		"source_session", p.sourceName,
129		"source_session_id", p.source.id,
130		"source_tab_id", p.sourceTab.stableID,
131		"destination_session", p.destinationName,
132		"destination_session_id", p.destination.id,
133		"source_retired", p.sourceEmpty,
134	}
135	if p.operation == "pane" {
136		attrs = append(attrs,
137			"source_pane_id", p.movedPane.stableID,
138			"destination_tab_id", p.destinationTab.stableID,
139		)
140	}
141	d.log.Info("move committed", attrs...)
142
143	// Repair attachment-local stable targets after shared topology publication
144	// and after the move dispatch locks have been released.
145	p.source.repairAttachmentViews()
146	if p.destination != p.source {
147		p.destination.repairAttachmentViews()
148	}
149
150	if !moveSessionRetired(d, p.source) {
151		sourceLayoutTab := p.sourceTab
152		if p.sourceTabRemoved {
153			sourceLayoutTab = p.source.firstTab()
154		}
155		if sourceLayoutTab != nil && sourceLayoutTab != p.destinationTab {
156			p.source.geometry.applyTabLayout(d, p.source, sourceLayoutTab)
157		}
158	}
159	p.destination.geometry.applyTabLayout(d, p.destination, p.destinationTab)
160	if p.sourceMetadataValid && !p.sourceEmpty {
161		d.markCatalogueDirty(p.sourceMetadata)
162	}
163	if p.destinationMetadataValid {
164		d.markCatalogueDirty(p.destinationMetadata)
165	}
166	for _, ac := range p.destination.snapshotAttachments() {
167		d.invalidateRender(p.destination, ac, true, "move.go")
168	}
169	if !p.sourceEmpty && p.source != p.destination {
170		for _, ac := range p.source.snapshotAttachments() {
171			d.invalidateRender(p.source, ac, true, "move.go")
172		}
173	}
174	if p.sourceEmpty {
175		retireEmptySessionAfterMove(d, p.source)
176	}
177	if len(p.retiredAttachments) != 0 {
178		d.log.Info("move detaching source attachments",
179			"operation", p.operation,
180			"source_session", p.sourceName,
181			"source_session_id", p.source.id,
182			"reason", "session-killed",
183			"attachments", len(p.retiredAttachments),
184		)
185	}
186	for _, attachment := range p.retiredAttachments {
187		attachment.ac.clearCaptureFrames()
188		if err := d.cleanupAttachmentOutput(attachment.ac); err != nil {
189			d.log.Warn("move attachment output cleanup failed", "err", err, "operation", p.operation, "source_session", p.sourceName)
190		}
191		d.notifyDetachedSnapshotAsync(attachment, protocol.ReasonSessionKilled)
192	}
193	for _, retirement := range p.retiredParked {
194		if retirement.parked == nil || retirement.parked.ac == nil {
195			continue
196		}
197		retirement.parked.ac.clearCaptureFrames()
198	}
199	d.finishParkedAttachmentRetirements(p.retiredParked)
200	if p.sourceEmpty && p.sourceMetadataValid && d.persistEnabled {
201		if err := d.beginSnapshotPurge(p.sourceName, p.source.incarnation); err == nil {
202			if err := d.finishSnapshotPurge(d.serveCtx, p.sourceName, p.source.incarnation, p.source.createdAt); err != nil {
203				d.log.Warn("moving final pane source purge failed", "err", err, "session", p.sourceName)
204			}
205		}
206	}
207}
208
209func moveSessionRetired(d *Daemon, sess *session) bool {
210	if d == nil || sess == nil {
211		return true
212	}
213	d.mu.Lock()
214	_, live := d.sessions[sess.id]
215	d.mu.Unlock()
216	return !live
217}
218
219// retireEmptySessionAfterMove completes only source-owned lifecycle work. The
220// moved pane has already been removed from source.tabs, so stopInMemoryLifecycle
221// cannot cancel or close the transferred PTY.
222func retireEmptySessionAfterMove(d *Daemon, sess *session) {
223	if d == nil || sess == nil {
224		return
225	}
226	sess.stopInMemoryLifecycle()
227	if coordinator := sess.renderCoordinator(); coordinator != nil {
228		coordinator.beginSessionTeardown().finish()
229		coordinator.waitForTimerWorkers()
230	}
231	sess.mu.Lock()
232	clipFiles := append([]string(nil), sess.clipFiles...)
233	sess.clipFiles = nil
234	sess.mu.Unlock()
235	for _, path := range clipFiles {
236		if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
237			d.log.Warn("removing clipboard temp file after move failed", "err", err, "path", path)
238		}
239	}
240}

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