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/usecase/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.procCwd != nil && focused.pty != nil {
365 if live, err := d.procCwd(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.cwd, spec.floatingCommand)
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}