v0.9.1 ece7682

floating.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"path/filepath"
6	"strings"
7
8	"github.com/bnema/vev/internal/domain"
9	"github.com/bnema/vev/internal/domain/layout"
10)
11
12// floatingState describes the lifecycle of a tab's independent floating pane.
13// A floating slot is always guarded by its owning tab.mu.
14type floatingState uint8
15
16const (
17	floatingUninitialized floatingState = iota
18	floatingWarming
19	floatingHidden
20	floatingVisible
21)
22
23// floatingSlot holds the runtime which is deliberately outside tab.tree and
24// tab.panes. generation identifies an asynchronous launch and its installed
25// pane, preventing stale launch completions and exits from changing new state.
26type floatingSlot struct {
27	state          floatingState
28	pane           *pane
29	desiredVisible bool
30	generation     uint64
31}
32
33// beginFloatingWarmLocked starts a single background launch. The caller must
34// hold tb.mu and must only call it for an uninitialized slot.
35func (tb *tab) beginFloatingWarmLocked(desiredVisible bool) uint64 {
36	if tb == nil || tb.floating.state != floatingUninitialized {
37		return 0
38	}
39	tb.floating.generation++
40	tb.floating.state = floatingWarming
41	tb.floating.desiredVisible = desiredVisible
42	return tb.floating.generation
43}
44
45// floatingTransferableLocked reports whether a tab's floating lifecycle can
46// cross an ownership commit. A warming Open remains registered to its source
47// session until installation or failure, so moves must reject only that state.
48// The caller must hold tb.mu.
49func (tb *tab) floatingTransferableLocked() bool {
50	return tb != nil && tb.floating.state != floatingWarming
51}
52
53// toggleFloatingLocked applies the user action. Its result says whether a new
54// PTY launch is required and returns the generation associated with that launch
55// or current slot.
56func (tb *tab) toggleFloatingLocked() (bool, uint64) {
57	if tb == nil {
58		return false, 0
59	}
60	switch tb.floating.state {
61	case floatingUninitialized:
62		generation := tb.beginFloatingWarmLocked(true)
63		return generation != 0, generation
64	case floatingWarming:
65		tb.floating.desiredVisible = !tb.floating.desiredVisible
66	case floatingHidden:
67		tb.floating.state = floatingVisible
68	case floatingVisible:
69		tb.floating.state = floatingHidden
70	}
71	return false, tb.floating.generation
72}
73
74// installFloatingLocked installs a completed launch only when it is still the
75// current warming generation. Stale completions must close their PTY elsewhere.
76func (tb *tab) installFloatingLocked(p *pane, generation uint64) bool {
77	if tb == nil || p == nil || tb.floating.state != floatingWarming || tb.floating.generation != generation {
78		return false
79	}
80	tb.floating.pane = p
81	if tb.floating.desiredVisible {
82		tb.floating.state = floatingVisible
83	} else {
84		tb.floating.state = floatingHidden
85	}
86	return true
87}
88
89// failFloatingLocked clears only the current warming launch. It deliberately
90// leaves generation intact; the next launch advances it.
91func (tb *tab) failFloatingLocked(generation uint64) bool {
92	if tb == nil || tb.floating.state != floatingWarming || tb.floating.generation != generation {
93		return false
94	}
95	tb.floating.state = floatingUninitialized
96	tb.floating.pane = nil
97	tb.floating.desiredVisible = false
98	return true
99}
100
101// clearFloatingLocked clears a matching installed runtime. Matching both pane
102// identity and generation prevents an old reader from reaping a newer pane.
103func (tb *tab) clearFloatingLocked(p *pane, generation uint64) bool {
104	if tb == nil || p == nil || tb.floating.pane != p || tb.floating.generation != generation ||
105		(tb.floating.state != floatingHidden && tb.floating.state != floatingVisible) {
106		return false
107	}
108	tb.takeFloatingLocked()
109	return true
110}
111
112// takeFloatingLocked invalidates the slot and detaches its installed pane. The
113// caller must hold tb.mu and must close the returned pane after unlocking.
114func (tb *tab) takeFloatingLocked() *pane {
115	if tb == nil {
116		return nil
117	}
118	p := tb.floating.pane
119	tb.floating.generation++
120	tb.floating.state = floatingUninitialized
121	tb.floating.pane = nil
122	tb.floating.desiredVisible = false
123	return p
124}
125
126// terminalTargetLocked chooses the visible floating terminal ahead of the
127// normal layout target. The caller must hold tb.mu.
128func (tb *tab) terminalTargetLocked() *pane {
129	if tb != nil && tb.floating.state == floatingVisible && tb.floating.pane != nil {
130		return tb.floating.pane
131	}
132	return tb.focusedPane()
133}
134
135// visibleFloatingSnapshotLocked captures the visible popup and its current
136// geometry under the tab lock, so input cannot mix a pane from one state with
137// coordinates from another. The caller must hold tb.mu.
138func (tb *tab) visibleFloatingSnapshotLocked(cfg domain.FloatingConfig) (*pane, floatingGeometry, bool) {
139	if tb == nil || tb.floating.state != floatingVisible || tb.floating.pane == nil {
140		return nil, floatingGeometry{}, false
141	}
142	p := tb.floating.pane
143	desired := calculateContentFloatingGeometry(domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}, cfg)
144	p.mu.Lock()
145	geometry := p.committedFloatingGeometryLocked(desired)
146	p.mu.Unlock()
147	if !geometry.committable() {
148		return nil, floatingGeometry{}, false
149	}
150	return p, geometry, true
151}
152
153// ensureFloatingWarm keeps the session's single pre-started floating shell
154// ready. Tabs no longer prewarm their own hidden shell; see floating_spare.go.
155func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) {
156	d.ensureFloatingSpare(sess, tb)
157}
158
159// activateTab performs the work associated with making a tab the destination.
160// It is deliberately idempotent: every tab transition can use this single
161// hook, while inactive restored tabs remain cold until actually selected.
162func (d *Daemon) activateTab(sess *session, tb *tab) bool {
163	return d.activateTabAfterResize(sess, tb, false)
164}
165
166// activateTabAfterResize retains tab activation's warmup work while avoiding a
167// second resize when a synchronous outer resize request was already accepted.
168func (d *Daemon) activateTabAfterResize(sess *session, tb *tab, outerResizeAccepted bool) bool {
169	return d.activateTabAfterResizeForLease(sess, tb, outerResizeAccepted, nil, nil)
170}
171
172// activateTabAfterResizeForLease keeps transition-owned resize effects bound to
173// the exact coordinator incarnation. A nil lease preserves direct/headless
174// activation behavior.
175func (d *Daemon) activateTabAfterResizeForLease(sess *session, tb *tab, outerResizeAccepted bool, ac *attachedClient, lease *attachmentLease) bool {
176	if d == nil || sess == nil || tb == nil {
177		return false
178	}
179	// A headless session has no attachment-local copy mode to clear. Keep its
180	// geometry work session-wide instead of choosing an arbitrary attachment.
181	if rc := sess.renderCoordinator(); rc != nil && rc.opts.onActivateTabAfterResize != nil {
182		rc.opts.onActivateTabAfterResize(lease != nil)
183	}
184	if lease != nil {
185		rc := sess.renderCoordinator()
186		if rc == nil || !rc.leaseCurrent(lease, true) {
187			return false
188		}
189	}
190	d.ensureFloatingWarm(sess, tb)
191	if ac == nil {
192		for _, attachment := range sess.snapshotAttachments() {
193			d.exitCopyMode(attachment)
194		}
195		if outerResizeAccepted {
196			return false
197		}
198		tb.mu.Lock()
199		hasFloating := tb.floating.pane != nil
200		size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
201		tb.mu.Unlock()
202		if hasFloating {
203			return sess.geometry.requestResize(d, sess, nil, size, true)
204		}
205		return false
206	}
207	d.exitCopyMode(ac)
208	if outerResizeAccepted {
209		return false
210	}
211	tb.mu.Lock()
212	hasFloating := tb.floating.pane != nil
213	size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
214	tb.mu.Unlock()
215	if hasFloating {
216		if lease != nil {
217			return sess.geometry.requestResizeForLease(d, sess, ac, lease, size, true)
218		}
219		return sess.geometry.requestResize(d, sess, ac, size, true)
220	}
221	return false
222}
223
224// toggleFloating changes only this tab's slot. Opening an uninitialized slot
225// launches asynchronously; installed slots are retained when hidden.
226func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error {
227	if d == nil || sess == nil {
228		return domain.UserErr(domain.NoticeFloatingSpawn, "couldn't open floating pane: no active session", nil)
229	}
230	tb := sess.tabForAttachment(ac)
231	if ac == nil {
232		tb = sess.firstTab()
233	}
234	if tb == nil {
235		return domain.UserErr(domain.NoticeFloatingSpawn, "couldn't open floating pane: no active tab", layout.ErrNotFound)
236	}
237	cfg := d.currentFloatingConfig()
238	tb.mu.Lock()
239	wasVisible := tb.floating.state == floatingVisible
240	start, generation := tb.toggleFloatingLocked()
241	// desiredVisible is meaningful only while a launch is warming. Installed
242	// slots derive their resize and paint work from the resulting slot state.
243	visible := tb.floating.state == floatingVisible
244	hidden := wasVisible && tb.floating.state == floatingHidden
245	tb.mu.Unlock()
246	if start {
247		d.launchFloating(sess, tb, cfg, generation, true)
248		if ac != nil {
249			d.invalidateRender(sess, ac, true, "floating.go")
250		}
251		return nil
252	}
253	if hidden {
254		d.invalidateRender(sess, ac, true, "floating.go")
255		return nil
256	}
257	if visible {
258		tb.mu.Lock()
259		size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
260		tb.mu.Unlock()
261		sess.geometry.requestResize(d, sess, ac, size, true)
262	}
263	return nil
264}
265
266// startFloating starts a prewarm if its slot remains uninitialized.
267func (d *Daemon) startFloating(sess *session, tb *tab, visible bool) {
268	cfg := d.currentFloatingConfig()
269	tb.mu.Lock()
270	generation := tb.beginFloatingWarmLocked(visible)
271	tb.mu.Unlock()
272	if generation != 0 {
273		d.launchFloating(sess, tb, cfg, generation, visible)
274	}
275}
276
277// floatingLaunchSpec is an immutable snapshot of one launch. It is built
278// before the worker starts so config reloads cannot affect that launch.
279// floatingLaunch owns one worker from queueing through installation or stale
280// result cleanup. Its done channel lets session teardown reap an in-flight
281// Open before returning.
282type floatingLaunch struct {
283	done chan struct{}
284}
285
286func (sess *session) registerFloatingLaunch() (*floatingLaunch, bool) {
287	launch := &floatingLaunch{done: make(chan struct{})}
288	sess.floatingLaunchMu.Lock()
289	defer sess.floatingLaunchMu.Unlock()
290	if sess.floatingLaunchStopping {
291		return nil, false
292	}
293	if sess.floatingLaunches == nil {
294		sess.floatingLaunches = make(map[*floatingLaunch]struct{})
295	}
296	sess.floatingLaunches[launch] = struct{}{}
297	return launch, true
298}
299
300func (sess *session) finishFloatingLaunch(launch *floatingLaunch) {
301	sess.floatingLaunchMu.Lock()
302	delete(sess.floatingLaunches, launch)
303	close(launch.done)
304	sess.floatingLaunchMu.Unlock()
305}
306
307func (sess *session) stopFloatingLaunches() {
308	sess.floatingLaunchMu.Lock()
309	sess.floatingLaunchStopping = true
310	sess.floatingLaunchMu.Unlock()
311}
312
313type floatingLaunchSpec struct {
314	sessionName  string
315	cwd          string
316	size         domain.Size
317	ptyGeometry  domain.Geometry
318	geometry     floatingGeometry
319	paneStableID string
320	env          []string
321	command      string
322	args         []string
323	fallback     string
324	parentCtx    context.Context
325	userOpen     bool
326	// floatingCommand is the configured floating.command; a spare is only
327	// claimed by a launch with the same command.
328	floatingCommand string
329}
330
331// launchFloating snapshots launch inputs, then accounts for a worker through
332// Open, pane initialization, publication, and reader/coordinator startup.
333func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfig, generation uint64, userOpen bool) {
334	spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, userOpen)
335	if err != nil {
336		d.failFloatingLaunch(sess, tb, generation, userOpen, spec.sessionName, err)
337		return
338	}
339	launch, ok := sess.registerFloatingLaunch()
340	if !ok {
341		d.failFloatingLaunch(sess, tb, generation, userOpen, spec.sessionName, context.Canceled)
342		return
343	}
344	// Count the launch before its goroutine starts. It remains counted through
345	// install, including the reader/coordinator Adds, so shutdown cannot return
346	// before a late Open completion has either been rejected or fully joined.
347	d.sessWg.Go(func() {
348		defer sess.finishFloatingLaunch(launch)
349		d.openAndInstallFloating(sess, tb, spec, generation)
350	})
351}
352
353func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.FloatingConfig, userOpen bool) (floatingLaunchSpec, error) {
354	sess.mu.Lock()
355	name, cwd, sessCtx := sess.name, sess.cwd, sess.ctx
356	env := copyEnvironment(sess.env)
357	sess.mu.Unlock()
358	tb.mu.Lock()
359	geometry := calculateContentFloatingGeometry(domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}, cfg)
360	size := rectSize(geometry.ptyRect())
361	focused := tb.focusedPane()
362	tabStableID, tabCtx := tb.stableID, tb.ctx
363	tb.mu.Unlock()
364	if focused != nil && d.proc != nil && focused.pty != nil {
365		if live, err := d.proc.Cwd(focused.pty.Pid()); err == nil && live != "" {
366			cwd = live
367		}
368	}
369	if d.dirOrHome != nil {
370		cwd = d.dirOrHome(cwd)
371	}
372	paneStableID, err := newStableID("p")
373	if err != nil {
374		return floatingLaunchSpec{sessionName: name}, err
375	}
376	if tabCtx == nil {
377		tabCtx = sessCtx
378	}
379	if tabCtx == nil {
380		tabCtx = context.Background()
381	}
382	launch := d.shellLaunch(env)
383	if cfg.Command != "" {
384		launch.args = []string{"-lc", cfg.Command}
385	}
386	return floatingLaunchSpec{
387		sessionName:     name,
388		cwd:             cwd,
389		size:            size,
390		ptyGeometry:     sess.geometry.paneGeometry(size),
391		geometry:        geometry,
392		paneStableID:    paneStableID,
393		env:             childEnvFrom(env, name, tabStableID, paneStableID),
394		command:         launch.command,
395		args:            launch.args,
396		fallback:        floatingCommandFallback(cfg.Command, launch.command),
397		parentCtx:       tabCtx,
398		userOpen:        userOpen,
399		floatingCommand: cfg.Command,
400	}, nil
401}
402
403// openAndInstallFloating runs entirely in the launch worker. No PTY operation
404// occurs under tb.mu; the pane is fully initialized before publication.
405func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64) {
406	if spec.userOpen {
407		// Claim the session spare when it matches this launch; a missing or
408		// mismatched spare falls through to a fresh launch. Once a spare was
409		// used up, the session starts the next one in the background.
410		p, had := sess.takeFloatingSpare(spec)
411		if had {
412			defer d.ensureFloatingSpare(sess, tb)
413		}
414		if p != nil {
415			d.adoptFloatingSpare(sess, tb, p, spec, generation)
416			return
417		}
418	}
419	// Open is context-aware and is intentionally called without any daemon,
420	// session, tab, or launch-ownership lock. Cancellation makes teardown
421	// bounded even if the adapter is waiting to create the child.
422	if err := spec.parentCtx.Err(); err != nil {
423		return
424	}
425	lifetime := d.newPaneProcessLifetime(spec.parentCtx, sess.ctx)
426	ownedByPane := false
427	defer func() {
428		if !ownedByPane {
429			lifetime.abort()
430		}
431	}()
432	pty, err := d.ptys.Open(lifetime.ctx, spec.command, spec.args, spec.env, spec.cwd, spec.ptyGeometry)
433	if err != nil {
434		// Open retains ownership of a nonnil PTY only on success. Some factory
435		// implementations can return a partially opened PTY with an error, so
436		// release both resources before publishing the launch failure.
437		lifetime.abort()
438		if pty != nil {
439			_ = pty.Close()
440		}
441		d.failFloatingLaunch(sess, tb, generation, spec.userOpen, spec.sessionName, err)
442		return
443	}
444	ptyGeometry := spec.ptyGeometry
445	p := newPaneWithStableIDAndTitle(layout.PaneID("floating"), spec.paneStableID, pty, spec.size, spec.fallback, d.currentHistoryConfig())
446	p.geometry = ptyGeometry
447	setScreenGeometry(p.screen, ptyGeometry)
448	p.rect = spec.geometry.ptyRect()
449	p.popupGeometry = spec.geometry
450	if !lifetime.publish(p) {
451		_ = pty.Close()
452		d.failFloatingLaunch(sess, tb, generation, spec.userOpen, spec.sessionName, lifetime.ctx.Err())
453		return
454	}
455	ownedByPane = true
456	// The reader may run as soon as install returns; make its exit policy
457	// immutable before publishing the pane to the slot. Installed panes resolve
458	// their owner dynamically so a transferred tab never reaps through source
459	// session pointers captured by its launch.
460	p.onExit = func() { d.reapInstalledFloating(p) }
461	d.installFloating(sess, tb, p, generation)
462}
463
464func (d *Daemon) failFloatingLaunch(sess *session, tb *tab, generation uint64, userOpen bool, sessionName string, err error) {
465	tb.mu.Lock()
466	current := tb.failFloatingLocked(generation)
467	tb.mu.Unlock()
468	if current {
469		kind := "background-prewarm"
470		if userOpen {
471			kind = "user-open"
472		}
473		d.log.Warn("floating pty spawn failed", "err", err, "session", sessionName, "kind", kind)
474		// A background prewarm is speculative: the user never asked for it, so
475		// a toast here would be confusing noise. Only a user-initiated open
476		// (the user pressed the toggle-floating key) earns a toast; if a
477		// prewarm failure matters, the user's later open attempt will fail
478		// too and surface then.
479		if userOpen {
480			d.reportError(sess, domain.UserErr(domain.NoticeFloatingSpawn,
481				"couldn't open floating pane: command failed to start", err))
482		}
483	}
484}
485
486// installFloating installs only the current generation. A stale successful
487// launch is cancelled and closed outside the tab lock.
488func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uint64) {
489	if sess == nil || tb == nil || p == nil {
490		closeFloatingPane(p)
491		return
492	}
493	tb.mu.Lock()
494	installable := tb.floating.state == floatingWarming && tb.floating.generation == generation
495	if installable {
496		// Publish under pane.mu before the slot becomes hidden or visible. The
497		// tab-to-pane lock order also linearizes owner and slot publication.
498		publishPaneOwner(p, sess, tb, generation)
499	}
500	installed := installable && tb.installFloatingLocked(p, generation)
501	visible := installed && tb.floating.state == floatingVisible
502	tb.mu.Unlock()
503	if !installed {
504		closeFloatingPane(p)
505		return
506	}
507	d.reapplyThemeSession(sess)
508	d.startPaneGoroutines(sess, tb, p)
509	if visible {
510		attachments := sess.snapshotAttachments()
511		if len(attachments) == 0 {
512			// A headless resize may commit while Open is warming. Reconcile the
513			// visible floating geometry even without an attachment callback.
514			sess.geometry.applyVisibleFloatingLayout(d, sess, tb, nil)
515			return
516		}
517		tb.mu.Lock()
518		size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
519		tb.mu.Unlock()
520		for _, ac := range attachments {
521			sess.geometry.requestResize(d, sess, ac, size, true)
522		}
523	}
524}
525
526// reapInstalledFloating resolves the pane's current immutable owner on every
527// attempt. If ownership changes before the tab lock is acquired, retrying the
528// lookup routes exit through the destination. The owner pointer and floating
529// slot generation are then checked together under tb.mu so an old exit can
530// never clear a reused slot.
531func (d *Daemon) reapInstalledFloating(p *pane) {
532	if p == nil {
533		return
534	}
535	for {
536		owner := p.ownerSnapshot()
537		if owner == nil || owner.session == nil || owner.tab == nil || owner.floatingSlotGeneration == 0 {
538			return
539		}
540		sess, tb, generation := owner.session, owner.tab, owner.floatingSlotGeneration
541		tb.mu.Lock()
542		if p.ownerSnapshot() != owner {
543			tb.mu.Unlock()
544			continue
545		}
546		visible := tb.floating.state == floatingVisible && tb.floating.pane == p && tb.floating.generation == generation
547		cleared := tb.clearFloatingLocked(p, generation)
548		tb.mu.Unlock()
549		if !cleared {
550			return
551		}
552		closeFloatingPane(p)
553		attachments := sess.snapshotAttachments()
554		copyCleared := false
555		for _, ac := range attachments {
556			ac.pruneCaptureFrames(p)
557			copyCleared = ac.overlays.clearCopyModeForPane(p) || copyCleared
558			if visible || copyCleared {
559				d.invalidateRender(sess, ac, true, "floating.go")
560			}
561		}
562		return
563	}
564}
565
566// teardownFloating invalidates first, then releases resources outside tb.mu.
567func (d *Daemon) teardownFloating(tb *tab, ac *attachedClient) {
568	if tb == nil {
569		return
570	}
571	tb.mu.Lock()
572	p := tb.takeFloatingLocked()
573	tb.mu.Unlock()
574	if ac != nil {
575		ac.overlays.clearCopyModeForPane(p)
576		ac.pruneCaptureFrames(p)
577	}
578	closeFloatingPane(p)
579}
580
581// closeFloatingPane releases a floating runtime at most once. It is called
582// only after a generation/identity check has detached the pane from its slot.
583func closeFloatingPane(p *pane) {
584	closePaneProcess(p)
585}
586
587func floatingCommandFallback(command, shell string) string {
588	if fields := strings.Fields(command); len(fields) > 0 {
589		return filepath.Base(fields[0])
590	}
591	return filepath.Base(shell)
592}

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