1package daemon
2
3import (
4 "errors"
5 "fmt"
6 "slices"
7
8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/domain/layout"
10)
11
12type paneFocusChange struct {
13 tab *tab
14 focused *pane
15 span domain.Rect
16 layoutChanged bool
17}
18
19func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Direction) error {
20 target := resolveDaemonActionTargetForAttachment(sess, ac)
21 change, err := d.splitPaneAt(sess, target.tab, target.pane, dir)
22 if err == nil && ac != nil {
23 publishPaneFocusForAttachment(sess, ac, change)
24 d.invalidateRender(sess, ac, true, "pane_actions.go")
25 }
26 return err
27}
28
29func (d *Daemon) splitPaneAt(sess *session, tb *tab, target *pane, dir layout.Direction) (paneFocusChange, error) {
30 after := dir == layout.Right || dir == layout.Down
31 return d.spawnPaneOpAt(sess, tb, target, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error {
32 return tree.Split(oldFocus, dir, after, newID, area)
33 })
34}
35
36func (d *Daemon) spawnPaneOpAt(
37 sess *session,
38 tb *tab,
39 target *pane,
40 mutate func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error,
41) (paneFocusChange, error) {
42 if d.ptys == nil {
43 return paneFocusChange{}, nil
44 }
45 if tb == nil || target == nil {
46 return paneFocusChange{}, layout.ErrNotFound
47 }
48
49 sess.mu.Lock()
50 name, cwd := sess.name, sess.cwd
51 env := copyEnvironment(sess.env)
52 sess.mu.Unlock()
53
54 tb.mu.Lock()
55 if tb.tree == nil || tb.tree.Root == nil {
56 tb.mu.Unlock()
57 return paneFocusChange{}, layout.ErrNotFound
58 }
59 oldFocus := target.id
60 if tb.panes[oldFocus] != target || !layout.ContainsLeaf(tb.tree.Root, oldFocus) {
61 tb.mu.Unlock()
62 return paneFocusChange{}, layout.ErrNotFound
63 }
64 newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID))
65 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
66 candidate := tb.tree.Clone()
67 if err := mutate(candidate, oldFocus, newID, area); err != nil {
68 tb.mu.Unlock()
69 if errors.Is(err, layout.ErrTooSmall) {
70 return paneFocusChange{}, domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", err)
71 }
72 return paneFocusChange{}, err
73 }
74 tb.nextPaneID++
75 placements, ok := layout.Solve(candidate.Root, area)
76 if !ok {
77 tb.mu.Unlock()
78 return paneFocusChange{}, domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", layout.ErrTooSmall)
79 }
80 newRect := placementContent(placements, newID)
81 tabStableID := tb.stableID
82 generation := tb.layoutGeneration
83 tb.mu.Unlock()
84 initialGeometry := sess.geometry.paneGeometry(rectSize(newRect))
85
86 paneStableID, err := newStableID("p")
87 if err != nil {
88 return paneFocusChange{}, fmt.Errorf("daemon: generating pane identity: %w", err)
89 }
90 launch := d.shellLaunch(env)
91 lifetime := d.newPaneProcessLifetime(tb.ctx)
92 pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID), cwd, initialGeometry)
93 if err != nil {
94 lifetime.abort()
95 if pty != nil {
96 _ = pty.Close()
97 }
98 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")
99 return paneFocusChange{}, domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err)
100 }
101 p := newPaneWithStableIDAndTitle(newID, paneStableID, pty, rectSize(newRect), launch.title, d.currentHistoryConfig())
102 p.geometry = initialGeometry
103 setScreenGeometry(p.screen, initialGeometry)
104 p.rect = newRect
105
106 tb.mu.Lock()
107 if tb.layoutGeneration != generation || tb.panes[oldFocus] != target || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, oldFocus) || tb.ctx != nil && tb.ctx.Err() != nil {
108 tb.mu.Unlock()
109 lifetime.abort()
110 _ = pty.Close()
111 return paneFocusChange{}, layout.ErrNotFound
112 }
113 if !lifetime.publish(p) {
114 tb.mu.Unlock()
115 _ = pty.Close()
116 return paneFocusChange{}, layout.ErrNotFound
117 }
118 // The tab lock excludes membership observers while pane.mu publishes the
119 // initial owner generation.
120 publishPaneOwner(p, sess, tb, 0)
121 tb.tree = candidate
122 tb.panes[newID] = p
123 tb.bumpLayoutGenerationLocked()
124 tb.mu.Unlock()
125
126 sess.geometry.applyTabLayout(d, sess, tb)
127 d.startPaneGoroutines(sess, tb, p)
128 return paneFocusChange{tab: tb, focused: p, layoutChanged: true}, nil
129}
130
131func placementContent(placements []layout.Placement, id layout.PaneID) domain.Rect {
132 for _, pl := range placements {
133 if pl.ID == id {
134 return pl.Content
135 }
136 }
137 return domain.Rect{}
138}
139
140func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} }
141
142func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {
143 target := resolveDaemonActionTargetForAttachment(sess, ac)
144 change, err := d.stackPaneAt(sess, target.tab, target.pane)
145 if err == nil && ac != nil {
146 publishPaneFocusForAttachment(sess, ac, change)
147 d.invalidateRender(sess, ac, true, "pane_actions.go")
148 }
149 return err
150}
151
152func (d *Daemon) stackPaneAt(sess *session, tb *tab, target *pane) (paneFocusChange, error) {
153 return d.spawnPaneOpAt(sess, tb, target, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error {
154 return tree.StackNew(oldFocus, newID, area)
155 })
156}
157
158func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
159 target := resolveDaemonActionTargetForAttachment(sess, ac)
160 err := d.toggleStackAt(sess, target.tab, target.pane)
161 if err == nil && ac != nil {
162 d.invalidateRender(sess, ac, true, "pane_actions.go")
163 }
164 return err
165}
166
167func (d *Daemon) toggleStackAt(sess *session, tb *tab, target *pane) error {
168 if d.ptys == nil {
169 return nil
170 }
171 if tb == nil || target == nil {
172 return layout.ErrNotFound
173 }
174 tb.mu.Lock()
175 if tb.tree == nil {
176 tb.mu.Unlock()
177 return layout.ErrNotFound
178 }
179 if tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {
180 tb.mu.Unlock()
181 return layout.ErrNotFound
182 }
183 candidate := tb.tree.Clone()
184 err := candidate.ToggleStack(target.id, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
185 if err == nil {
186 tb.tree = candidate
187 tb.bumpLayoutGenerationLocked()
188 }
189 tb.mu.Unlock()
190 if err == nil {
191 sess.geometry.applyTabLayout(d, sess, tb)
192 }
193 return err
194}
195
196func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error {
197 if d.ptys == nil {
198 if ac != nil {
199 d.invalidateRender(sess, ac, true, "pane_actions.go")
200 }
201 return nil
202 }
203 target := resolveDaemonActionTargetForAttachment(sess, ac)
204 tb := target.tab
205 if tb == nil {
206 return layout.ErrNotFound
207 }
208 if target.pane == nil {
209 return layout.ErrNotFound
210 }
211 return d.closePane(sess, tb, target.pane.id, ac, true)
212}
213
214// reapPane retains the explicit-owner entry point for focused close tests. PTY
215// readers use reapPaneOwner so an owner publication cannot redirect exit to a
216// replacement that reused the same tab-local pane ID.
217func (d *Daemon) reapPane(sess *session, tb *tab, p *pane) {
218 if p == nil {
219 return
220 }
221 lease := p.effectLease()
222 if lease.owner == nil {
223 _ = d.closePane(sess, tb, p.id, nil, true)
224 return
225 }
226 if lease.owner.session != sess || lease.owner.tab != tb {
227 return
228 }
229 d.reapPaneOwner(p)
230}
231
232// reapPaneOwner resolves the owner afresh after EOF. A stale attempt retries
233// the immutable owner pointer. Non-final removal revokes it with membership;
234// final-pane removal retains it until tab/session teardown commits, so stale
235// attachment snapshots cannot abandon the EOF before closing its resources.
236func (d *Daemon) reapPaneOwner(p *pane) {
237 for p != nil {
238 lease := p.effectLease()
239 if lease.owner == nil {
240 return
241 }
242 if d.reapTiledPaneLease(lease) {
243 return
244 }
245 }
246}
247
248func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
249 p, owner := lease.pane, lease.owner
250 if p == nil || owner == nil || owner.session == nil || owner.tab == nil || owner.floatingSlotGeneration != 0 {
251 return true
252 }
253 sess, tb := owner.session, owner.tab
254 // EOF teardown is a shared mutation just like an explicit close. Hold the
255 // dispatch boundary before any session/tab lock, then use the locked close
256 // helpers below without re-entering it.
257 sess.dispatchMu.Lock()
258 defer sess.dispatchMu.Unlock()
259 sess.mu.Lock()
260 ownedTab := slices.Contains(sess.tabs, tb)
261 if p.owner.Load() != owner {
262 sess.mu.Unlock()
263 return false
264 }
265 if !ownedTab {
266 sess.mu.Unlock()
267 return true
268 }
269 attachments := sess.snapshotAttachmentsLocked()
270 sessionName := sess.name
271 tb.mu.Lock()
272 if p.owner.Load() != owner {
273 tb.mu.Unlock()
274 sess.mu.Unlock()
275 return false
276 }
277 if tb.panes[p.id] != p || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, p.id) {
278 tb.mu.Unlock()
279 sess.mu.Unlock()
280 return true
281 }
282 p.mu.Lock()
283 if p.owner.Load() != owner {
284 p.mu.Unlock()
285 tb.mu.Unlock()
286 sess.mu.Unlock()
287 return false
288 }
289 finalPane := len(layout.LeafIDs(tb.tree.Root)) <= 1
290 if !finalPane {
291 if err := tb.tree.Close(p.id); err != nil {
292 p.mu.Unlock()
293 tb.mu.Unlock()
294 sess.mu.Unlock()
295 return true
296 }
297 delete(tb.panes, p.id)
298 tb.bumpLayoutGenerationLocked()
299 }
300 if !finalPane {
301 p.clearOwnerLocked()
302 }
303 p.mu.Unlock()
304 tb.mu.Unlock()
305 sess.invalidateViewsLocked()
306 sess.mu.Unlock()
307 attachmentTransports := make(map[*attachedClient]transportSnapshot, len(attachments))
308 for _, ac := range attachments {
309 attachmentTransports[ac] = ac.transportSnapshot()
310 }
311
312 if finalPane {
313 for _, ac := range attachments {
314 if ac.overlays != nil {
315 ac.overlays.clearCopyModeForPane(p)
316 }
317 }
318 // A concurrent detach or token resume that invalidated the teardown's
319 // participant snapshot is itself observable progress: the next owner
320 // resolution consumes the fresh participant set, so retry instead of
321 // treating the abort as a completed close.
322 participantsChanged := false
323 if err := d.closeTabLocked(sess, tb, true); err != nil {
324 if !errors.Is(err, errSessionKillParticipantsChanged) {
325 return true
326 }
327 participantsChanged = true
328 }
329 if p.owner.Load() == nil {
330 return true
331 }
332 // A successful no-op teardown means participant, gate, or lifecycle
333 // validation rejected the mutation without changing either the tab list
334 // or the pane owner. That includes an explicit participants-changed
335 // sentinel: the kill aborted because a concurrent detach or transport
336 // replacement invalidated its snapshot, which the owner resolution below
337 // consumes. Retrying that exact state would spin forever; only retry when
338 // the teardown made observable progress that can be consumed by the next
339 // owner resolution.
340 d.mu.Lock()
341 sessionCurrent := d.sessions[sess.id] == sess
342 sess.mu.Lock()
343 tabCurrent := slices.Contains(sess.tabs, tb)
344 attachmentsChanged := len(sess.attachments) != len(attachmentTransports)
345 if !attachmentsChanged {
346 for ac, transport := range attachmentTransports {
347 if _, attached := sess.attachments[ac]; !attached || !ac.transportSnapshotCurrent(transport) {
348 attachmentsChanged = true
349 break
350 }
351 }
352 }
353 sess.mu.Unlock()
354 d.mu.Unlock()
355 if sessionCurrent && tabCurrent && !attachmentsChanged && !participantsChanged {
356 return true
357 }
358 return false
359 }
360 sess.geometry.applyTabLayout(d, sess, tb)
361 for _, ac := range attachments {
362 if ac.overlays != nil {
363 ac.overlays.clearCopyModeForPane(p)
364 }
365 ac.pruneCaptureFrames(p)
366 }
367 if rc := sess.renderCoordinator(); rc != nil {
368 rc.noteSyncPaneRemoved(p)
369 }
370 closePaneProcess(p)
371 d.log.Info("pane closed", "session", sessionName, "pane", p.id)
372 for _, ac := range attachments {
373 d.invalidateRender(sess, ac, true, "pane_actions.go")
374 }
375 return true
376}
377
378// closePane is the public close boundary for callers that are not already
379// inside session.runMutation. Mutation dispatchers use closePaneLocked.
380func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool) error {
381 if sess == nil {
382 return layout.ErrNotFound
383 }
384 sess.dispatchMu.Lock()
385 defer sess.dispatchMu.Unlock()
386 return d.closePaneLocked(sess, tb, id, ac, repaint)
387}
388
389// closePaneLocked requires sess.dispatchMu before entering any architecture
390// lock. It is used by existing runMutation callers to avoid nested dispatch
391// locking.
392func (d *Daemon) closePaneLocked(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool) error {
393 return d.closePaneLockedWithEffect(sess, tb, id, ac, repaint, nil)
394}
395
396func (d *Daemon) closePaneLockedWithEffect(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool, effect *attachmentEffect) error {
397 if tb == nil {
398 return layout.ErrNotFound
399 }
400 // A nil attachment denotes a session-wide close with an explicit pane target.
401 attachments := sess.snapshotAttachments()
402 tb.mu.Lock()
403 p := tb.panes[id]
404 if p == nil || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, id) {
405 tb.mu.Unlock()
406 return nil
407 }
408 if len(layout.LeafIDs(tb.tree.Root)) <= 1 {
409 tb.mu.Unlock()
410 for _, viewer := range attachments {
411 if viewer.overlays != nil {
412 viewer.overlays.clearCopyModeForPane(p)
413 }
414 viewer.pruneCaptureFrames(p)
415 }
416 return d.closeTabLockedWithEffect(sess, tb, repaint, effect)
417 }
418 if err := tb.tree.Close(id); err != nil {
419 tb.mu.Unlock()
420 return err
421 }
422 delete(tb.panes, id)
423 tb.bumpLayoutGenerationLocked()
424 tb.mu.Unlock()
425 sess.mu.Lock()
426 sess.invalidateViewsLocked()
427 sess.mu.Unlock()
428 sess.geometry.applyTabLayout(d, sess, tb)
429
430 for _, viewer := range attachments {
431 if viewer.overlays != nil {
432 viewer.overlays.clearCopyModeForPane(p)
433 }
434 viewer.pruneCaptureFrames(p)
435 }
436
437 if rc := sess.renderCoordinator(); rc != nil {
438 rc.noteSyncPaneRemoved(p)
439 }
440 closePaneProcess(p)
441 d.log.Info("pane closed", "session", sess.name, "pane", id)
442 if repaint {
443 if ac != nil {
444 d.invalidateRender(sess, ac, true, "pane_actions.go")
445 } else {
446 for _, viewer := range attachments {
447 d.invalidateRender(sess, viewer, true, "pane_actions.go")
448 }
449 }
450 }
451 return nil
452}
453
454func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction, effect *attachmentEffect) error {
455 target := resolveDaemonActionTargetForAttachment(sess, ac)
456 oldFocus := layout.PaneID("")
457 if target.pane != nil {
458 oldFocus = target.pane.id
459 }
460 change, err := d.focusDirAt(sess, target.tab, target.pane, dir)
461 if err == nil {
462 if ac != nil {
463 publishPaneFocusForAttachment(sess, ac, change)
464 d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go")
465 }
466 return nil
467 }
468 span := change.span
469 if !errors.Is(err, errNoNeighbor) || target.tab == nil {
470 return err
471 }
472 if !overflowSourceEligible(sess, ac, target.tab) {
473 return errNoNeighbor
474 }
475
476 cfg := d.currentNavConfig()
477 if sessionTarget, ok := d.prepareSessionOverflow(sess, dir, cfg); ok {
478 if ac == nil {
479 return errNoNeighbor
480 }
481 if effect != nil {
482 return d.switchToTargetForAttachment(effect, sessionTarget, sessionHandoffGuard{expectedSource: target.tab}, "overflow-session")
483 }
484 return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)
485 }
486
487 position, count := sess.tabIndexForAttachment(ac)
488 step := resolveOverflow(dir, cfg, position, count)
489 if step.kind != overflowTabs {
490 return err
491 }
492 candidate, ok := d.prepareTabOverflowForAttachment(sess, ac, target.tab, dir, span, step.delta)
493 if !ok || !d.commitTabOverflowForAttachment(sess, ac, candidate) {
494 return errNoNeighbor
495 }
496 if ac != nil {
497 sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID))
498 d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "pane_actions.go")
499 }
500 return nil
501}
502
503func publishPaneFocusForAttachment(sess *session, ac *attachedClient, change paneFocusChange) bool {
504 if sess == nil || ac == nil || change.tab == nil || change.focused == nil {
505 return false
506 }
507 sess.mu.Lock()
508 changed := sess.setAttachmentPaneLocked(ac, change.tab, change.focused)
509 sess.mu.Unlock()
510 return changed
511}
512
513// finishPaneFocusForClient applies the attachment lifecycle shared by every
514// successful directional focus move. Keep copy-mode exit before the optional
515// stack title refresh, and both before render invalidation.
516func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb *tab, oldFocus layout.PaneID, producer string) {
517 var newFocus layout.PaneID
518 var newPane *pane
519 var pl layout.Placement
520 var hasPlacement bool
521 if tb != nil {
522 tb.mu.Lock()
523 if ac != nil {
524 view := ac.viewSnapshot()
525 if domain.TabStableID(tb.stableID) == view.tabID {
526 for _, candidate := range tb.panes {
527 if candidate != nil && domain.PaneStableID(candidate.stableID) == view.paneID {
528 newPane = candidate
529 break
530 }
531 }
532 }
533 } else {
534 newPane = tb.focusedPane()
535 if newPane != nil {
536 newFocus = newPane.id
537 }
538 }
539 if newPane != nil {
540 newFocus = newPane.id
541 pl, hasPlacement = panePlacementLocked(tb, newFocus)
542 }
543 tb.mu.Unlock()
544 }
545
546 if newFocus != oldFocus {
547 d.exitCopyMode(ac)
548 if hasPlacement && pl.InStack {
549 d.refreshPaneTitleOnFocus(sess, newFocus, tb)
550 }
551 }
552 d.invalidateRender(sess, ac, true, producer)
553}
554
555func panePlacementLocked(tb *tab, id layout.PaneID) (layout.Placement, bool) {
556 placements, ok := solvedPlacementsLocked(tb)
557 if !ok {
558 return layout.Placement{}, false
559 }
560 for _, placement := range placements {
561 if placement.ID == id {
562 return placement, true
563 }
564 }
565 return layout.Placement{}, false
566}
567
568func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) (paneFocusChange, error) {
569 change := paneFocusChange{tab: tb}
570 if tb == nil || target == nil {
571 return change, layout.ErrNotFound
572 }
573 tb.mu.Lock()
574 if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {
575 tb.mu.Unlock()
576 return change, layout.ErrNotFound
577 }
578 candidate := tb.tree.Clone()
579 candidate.Focus = target.id
580 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
581 change.span, _ = candidate.FocusSpan(area)
582 err := candidate.FocusDir(dir, area)
583 if err == nil {
584 change.focused = tb.panes[candidate.Focus]
585 change.layoutChanged = change.focused != nil && (candidate.Focus != tb.tree.Focus || layoutFingerprint(candidate.Root) != layoutFingerprint(tb.tree.Root))
586 if change.layoutChanged {
587 tb.tree = candidate
588 tb.bumpLayoutGenerationLocked()
589 }
590 }
591 tb.mu.Unlock()
592 if change.layoutChanged {
593 sess.geometry.applyTabLayout(d, sess, tb)
594 }
595 if errors.Is(err, layout.ErrNoPane) {
596 return change, errNoNeighbor
597 }
598 return change, err
599}