1package daemon
2
3import (
4 "errors"
5 "fmt"
6 "slices"
7
8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/usecase/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 term := sess.terminal
52 env := copyEnvironment(sess.env)
53 sess.mu.Unlock()
54
55 tb.mu.Lock()
56 if tb.tree == nil || tb.tree.Root == nil {
57 tb.mu.Unlock()
58 return paneFocusChange{}, layout.ErrNotFound
59 }
60 oldFocus := target.id
61 if tb.panes[oldFocus] != target || !layout.ContainsLeaf(tb.tree.Root, oldFocus) {
62 tb.mu.Unlock()
63 return paneFocusChange{}, layout.ErrNotFound
64 }
65 newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID))
66 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
67 candidate := tb.tree.Clone()
68 if err := mutate(candidate, oldFocus, newID, area); err != nil {
69 tb.mu.Unlock()
70 if errors.Is(err, layout.ErrTooSmall) {
71 return paneFocusChange{}, domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", err)
72 }
73 return paneFocusChange{}, err
74 }
75 tb.nextPaneID++
76 placements, ok := layout.Solve(candidate.Root, area)
77 if !ok {
78 tb.mu.Unlock()
79 return paneFocusChange{}, domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to split", layout.ErrTooSmall)
80 }
81 newRect := placementContent(placements, newID)
82 tabStableID := tb.stableID
83 generation := tb.layoutGeneration
84 tb.mu.Unlock()
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, term), cwd, rectSize(newRect))
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
102 p := newPaneWithStableIDAndTitle(newID, paneStableID, pty, rectSize(newRect), launch.title)
103 p.rect = newRect
104
105 tb.mu.Lock()
106 if tb.layoutGeneration != generation || tb.panes[oldFocus] != target || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, oldFocus) || tb.ctx != nil && tb.ctx.Err() != nil {
107 tb.mu.Unlock()
108 lifetime.abort()
109 _ = pty.Close()
110 return paneFocusChange{}, layout.ErrNotFound
111 }
112 if !lifetime.publish(p) {
113 tb.mu.Unlock()
114 _ = pty.Close()
115 return paneFocusChange{}, layout.ErrNotFound
116 }
117 // The tab lock excludes membership observers while pane.mu publishes the
118 // initial owner generation.
119 publishPaneOwner(p, sess, tb, 0)
120 tb.tree = candidate
121 tb.panes[newID] = p
122 tb.bumpLayoutGenerationLocked()
123 tb.mu.Unlock()
124
125 d.applyTabLayout(sess, tb)
126 d.startPaneGoroutines(sess, tb, p)
127 return paneFocusChange{tab: tb, focused: p, layoutChanged: true}, nil
128}
129
130func placementContent(placements []layout.Placement, id layout.PaneID) domain.Rect {
131 for _, pl := range placements {
132 if pl.ID == id {
133 return pl.Content
134 }
135 }
136 return domain.Rect{}
137}
138
139func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} }
140
141func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {
142 target := resolveDaemonActionTargetForAttachment(sess, ac)
143 change, err := d.stackPaneAt(sess, target.tab, target.pane)
144 if err == nil && ac != nil {
145 publishPaneFocusForAttachment(sess, ac, change)
146 d.invalidateRender(sess, ac, true, "pane_actions.go")
147 }
148 return err
149}
150
151func (d *Daemon) stackPaneAt(sess *session, tb *tab, target *pane) (paneFocusChange, error) {
152 return d.spawnPaneOpAt(sess, tb, target, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error {
153 return tree.StackNew(oldFocus, newID, area)
154 })
155}
156
157func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
158 target := resolveDaemonActionTargetForAttachment(sess, ac)
159 err := d.toggleStackAt(sess, target.tab, target.pane)
160 if err == nil && ac != nil {
161 d.invalidateRender(sess, ac, true, "pane_actions.go")
162 }
163 return err
164}
165
166func (d *Daemon) toggleStackAt(sess *session, tb *tab, target *pane) error {
167 if d.ptys == nil {
168 return nil
169 }
170 if tb == nil || target == nil {
171 return layout.ErrNotFound
172 }
173 tb.mu.Lock()
174 if tb.tree == nil {
175 tb.mu.Unlock()
176 return layout.ErrNotFound
177 }
178 if tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {
179 tb.mu.Unlock()
180 return layout.ErrNotFound
181 }
182 candidate := tb.tree.Clone()
183 err := candidate.ToggleStack(target.id, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
184 if err == nil {
185 tb.tree = candidate
186 tb.bumpLayoutGenerationLocked()
187 }
188 tb.mu.Unlock()
189 if err == nil {
190 d.applyTabLayout(sess, tb)
191 }
192 return err
193}
194
195func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error {
196 if d.ptys == nil {
197 if ac != nil {
198 d.invalidateRender(sess, ac, true, "pane_actions.go")
199 }
200 return nil
201 }
202 target := resolveDaemonActionTargetForAttachment(sess, ac)
203 tb := target.tab
204 if tb == nil {
205 return layout.ErrNotFound
206 }
207 if target.pane == nil {
208 return layout.ErrNotFound
209 }
210 return d.closePane(sess, tb, target.pane.id, ac, true)
211}
212
213// reapPane retains the explicit-owner entry point for focused close tests. PTY
214// readers use reapPaneOwner so an owner publication cannot redirect exit to a
215// replacement that reused the same tab-local pane ID.
216func (d *Daemon) reapPane(sess *session, tb *tab, p *pane) {
217 if p == nil {
218 return
219 }
220 lease := p.effectLease()
221 if lease.owner == nil {
222 _ = d.closePane(sess, tb, p.id, nil, true)
223 return
224 }
225 if lease.owner.session != sess || lease.owner.tab != tb {
226 return
227 }
228 d.reapPaneOwner(p)
229}
230
231// reapPaneOwner resolves the owner afresh after EOF. A stale attempt retries
232// the immutable owner pointer; a successful attempt revokes owner publication
233// under pane.mu before releasing membership locks, so a competing close or
234// move can never reap the pane from a second owner.
235func (d *Daemon) reapPaneOwner(p *pane) {
236 for p != nil {
237 lease := p.effectLease()
238 if lease.owner == nil {
239 return
240 }
241 if d.reapTiledPaneLease(lease) {
242 return
243 }
244 }
245}
246
247func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
248 p, owner := lease.pane, lease.owner
249 if p == nil || owner == nil || owner.session == nil || owner.tab == nil || owner.floatingSlotGeneration != 0 {
250 return true
251 }
252 sess, tb := owner.session, owner.tab
253 // EOF teardown is a shared mutation just like an explicit close. Hold the
254 // dispatch boundary before any session/tab lock, then use the locked close
255 // helpers below without re-entering it.
256 sess.dispatchMu.Lock()
257 defer sess.dispatchMu.Unlock()
258 sess.mu.Lock()
259 ownedTab := slices.Contains(sess.tabs, tb)
260 if p.owner.Load() != owner {
261 sess.mu.Unlock()
262 return false
263 }
264 if !ownedTab {
265 sess.mu.Unlock()
266 return true
267 }
268 attachments := sess.snapshotAttachmentsLocked()
269 sessionName := sess.name
270 tb.mu.Lock()
271 if p.owner.Load() != owner {
272 tb.mu.Unlock()
273 sess.mu.Unlock()
274 return false
275 }
276 if tb.panes[p.id] != p || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, p.id) {
277 tb.mu.Unlock()
278 sess.mu.Unlock()
279 return true
280 }
281 p.mu.Lock()
282 if p.owner.Load() != owner {
283 p.mu.Unlock()
284 tb.mu.Unlock()
285 sess.mu.Unlock()
286 return false
287 }
288 finalPane := len(layout.LeafIDs(tb.tree.Root)) <= 1
289 if !finalPane {
290 if err := tb.tree.Close(p.id); err != nil {
291 p.mu.Unlock()
292 tb.mu.Unlock()
293 sess.mu.Unlock()
294 return true
295 }
296 delete(tb.panes, p.id)
297 tb.bumpLayoutGenerationLocked()
298 }
299 p.clearOwnerLocked()
300 p.mu.Unlock()
301 tb.mu.Unlock()
302 sess.invalidateViewsLocked()
303 sess.mu.Unlock()
304
305 if finalPane {
306 for _, ac := range attachments {
307 if ac.overlays != nil {
308 ac.overlays.clearCopyModeForPane(p)
309 }
310 }
311 _ = d.closeTabLocked(sess, tb, true)
312 return true
313 }
314 d.applyTabLayout(sess, tb)
315 for _, ac := range attachments {
316 if ac.overlays != nil {
317 ac.overlays.clearCopyModeForPane(p)
318 }
319 ac.pruneCaptureFrames(p)
320 }
321 if rc := sess.renderCoordinator(); rc != nil {
322 rc.noteSyncPaneRemoved(p)
323 }
324 closePaneProcess(p)
325 d.log.Info("pane closed", "session", sessionName, "pane", p.id)
326 for _, ac := range attachments {
327 d.invalidateRender(sess, ac, true, "pane_actions.go")
328 }
329 return true
330}
331
332// closePane is the public close boundary for callers that are not already
333// inside session.runMutation. Mutation dispatchers use closePaneLocked.
334func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool) error {
335 if sess == nil {
336 return layout.ErrNotFound
337 }
338 sess.dispatchMu.Lock()
339 defer sess.dispatchMu.Unlock()
340 return d.closePaneLocked(sess, tb, id, ac, repaint)
341}
342
343// closePaneLocked requires sess.dispatchMu before entering any architecture
344// lock. It is used by existing runMutation callers to avoid nested dispatch
345// locking.
346func (d *Daemon) closePaneLocked(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool) error {
347 return d.closePaneLockedWithEffect(sess, tb, id, ac, repaint, nil)
348}
349
350func (d *Daemon) closePaneLockedWithEffect(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool, effect *attachmentEffectTicket) error {
351 if tb == nil {
352 return layout.ErrNotFound
353 }
354 // A nil attachment denotes a session-wide close with an explicit pane target.
355 attachments := sess.snapshotAttachments()
356 tb.mu.Lock()
357 p := tb.panes[id]
358 if p == nil || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, id) {
359 tb.mu.Unlock()
360 return nil
361 }
362 if len(layout.LeafIDs(tb.tree.Root)) <= 1 {
363 tb.mu.Unlock()
364 for _, viewer := range attachments {
365 if viewer.overlays != nil {
366 viewer.overlays.clearCopyModeForPane(p)
367 }
368 viewer.pruneCaptureFrames(p)
369 }
370 return d.closeTabLockedWithEffect(sess, tb, repaint, effect)
371 }
372 if err := tb.tree.Close(id); err != nil {
373 tb.mu.Unlock()
374 return err
375 }
376 delete(tb.panes, id)
377 tb.bumpLayoutGenerationLocked()
378 tb.mu.Unlock()
379 sess.mu.Lock()
380 sess.invalidateViewsLocked()
381 sess.mu.Unlock()
382 d.applyTabLayout(sess, tb)
383
384 for _, viewer := range attachments {
385 if viewer.overlays != nil {
386 viewer.overlays.clearCopyModeForPane(p)
387 }
388 viewer.pruneCaptureFrames(p)
389 }
390
391 if rc := sess.renderCoordinator(); rc != nil {
392 rc.noteSyncPaneRemoved(p)
393 }
394 closePaneProcess(p)
395 d.log.Info("pane closed", "session", sess.name, "pane", id)
396 if repaint {
397 if ac != nil {
398 d.invalidateRender(sess, ac, true, "pane_actions.go")
399 } else {
400 for _, viewer := range attachments {
401 d.invalidateRender(sess, viewer, true, "pane_actions.go")
402 }
403 }
404 }
405 return nil
406}
407
408func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction, effect *attachmentEffectTicket) error {
409 target := resolveDaemonActionTargetForAttachment(sess, ac)
410 oldFocus := layout.PaneID("")
411 if target.pane != nil {
412 oldFocus = target.pane.id
413 }
414 change, err := d.focusDirAt(sess, target.tab, target.pane, dir)
415 if err == nil {
416 if ac != nil {
417 publishPaneFocusForAttachment(sess, ac, change)
418 d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go")
419 }
420 return nil
421 }
422 span := change.span
423 if !errors.Is(err, errNoNeighbor) || target.tab == nil {
424 return err
425 }
426 if !overflowSourceEligible(sess, ac, target.tab) {
427 return errNoNeighbor
428 }
429
430 cfg := d.currentNavConfig()
431 if sessionTarget, ok := d.prepareSessionOverflow(sess, dir, cfg); ok {
432 if ac == nil {
433 return errNoNeighbor
434 }
435 if effect != nil {
436 return d.switchToTargetForAttachment(effect.connectionToken(), sessionTarget, sessionHandoffGuard{expectedSource: target.tab}, "overflow-session")
437 }
438 return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)
439 }
440
441 position, count := sess.tabIndexForAttachment(ac)
442 step := resolveOverflow(dir, cfg, position, count)
443 if step.kind != overflowTabs {
444 return err
445 }
446 candidate, ok := d.prepareTabOverflowForAttachment(sess, ac, target.tab, dir, span, step.delta)
447 if !ok || !d.commitTabOverflowForAttachment(sess, ac, candidate) {
448 return errNoNeighbor
449 }
450 if ac != nil {
451 sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID))
452 d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "pane_actions.go")
453 }
454 return nil
455}
456
457func publishPaneFocusForAttachment(sess *session, ac *attachedClient, change paneFocusChange) bool {
458 if sess == nil || ac == nil || change.tab == nil || change.focused == nil {
459 return false
460 }
461 sess.mu.Lock()
462 changed := sess.setAttachmentPaneLocked(ac, change.tab, change.focused)
463 sess.mu.Unlock()
464 return changed
465}
466
467// finishPaneFocusForClient applies the attachment lifecycle shared by every
468// successful directional focus move. Keep copy-mode exit before the optional
469// stack title refresh, and both before render invalidation.
470func (d *Daemon) finishPaneFocusForClient(sess *session, ac *attachedClient, tb *tab, oldFocus layout.PaneID, producer string) {
471 var newFocus layout.PaneID
472 var newPane *pane
473 var pl layout.Placement
474 var hasPlacement bool
475 if tb != nil {
476 tb.mu.Lock()
477 if ac != nil {
478 view := ac.viewSnapshot()
479 if domain.TabStableID(tb.stableID) == view.tabID {
480 for _, candidate := range tb.panes {
481 if candidate != nil && domain.PaneStableID(candidate.stableID) == view.paneID {
482 newPane = candidate
483 break
484 }
485 }
486 }
487 } else {
488 newPane = tb.focusedPane()
489 if newPane != nil {
490 newFocus = newPane.id
491 }
492 }
493 if newPane != nil {
494 newFocus = newPane.id
495 pl, hasPlacement = panePlacementLocked(tb, newFocus)
496 }
497 tb.mu.Unlock()
498 }
499
500 if newFocus != oldFocus {
501 d.exitCopyMode(ac)
502 if hasPlacement && pl.InStack {
503 d.refreshPaneTitleOnFocus(sess, newFocus, tb)
504 }
505 }
506 d.invalidateRender(sess, ac, true, producer)
507}
508
509func panePlacementLocked(tb *tab, id layout.PaneID) (layout.Placement, bool) {
510 placements, ok := solvedPlacementsLocked(tb)
511 if !ok {
512 return layout.Placement{}, false
513 }
514 for _, placement := range placements {
515 if placement.ID == id {
516 return placement, true
517 }
518 }
519 return layout.Placement{}, false
520}
521
522func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) (paneFocusChange, error) {
523 change := paneFocusChange{tab: tb}
524 if tb == nil || target == nil {
525 return change, layout.ErrNotFound
526 }
527 tb.mu.Lock()
528 if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) {
529 tb.mu.Unlock()
530 return change, layout.ErrNotFound
531 }
532 candidate := tb.tree.Clone()
533 candidate.Focus = target.id
534 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
535 change.span, _ = candidate.FocusSpan(area)
536 err := candidate.FocusDir(dir, area)
537 if err == nil {
538 change.focused = tb.panes[candidate.Focus]
539 change.layoutChanged = change.focused != nil && (candidate.Focus != tb.tree.Focus || layoutFingerprint(candidate.Root) != layoutFingerprint(tb.tree.Root))
540 if change.layoutChanged {
541 tb.tree = candidate
542 tb.bumpLayoutGenerationLocked()
543 }
544 }
545 tb.mu.Unlock()
546 if change.layoutChanged {
547 d.applyTabLayout(sess, tb)
548 }
549 if errors.Is(err, layout.ErrNoPane) {
550 return change, errNoNeighbor
551 }
552 return change, err
553}