1package daemon
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "runtime"
9 "slices"
10 "sort"
11 "strings"
12
13 "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/ports"
15 "github.com/bnema/vev/internal/protocol"
16 "github.com/bnema/vev/internal/protocol/catalogue"
17 "github.com/bnema/vev/internal/usecase/command"
18 "github.com/bnema/vev/internal/usecase/layout"
19)
20
21// handleCommand serves one typed one-shot control request.
22func (d *Daemon) handleCommand(tr ports.ServerConnection, request protocol.CommandRequest) error {
23 defer func() { _ = tr.Close() }()
24
25 if request.Version != protocol.Version {
26 return d.sendCommandResult(tr, protocol.CommandResult{RequestID: request.RequestID, Outcome: protocol.CommandFailed, Code: protocol.ErrVersionMismatch, Text: "protocol version mismatch"})
27 }
28 if request.Attached {
29 return d.sendCommandResult(tr, protocol.CommandResult{
30 RequestID: request.RequestID,
31 Outcome: protocol.CommandFailed,
32 Code: protocol.ErrNotScriptable,
33 Text: "attached command relay is not enabled",
34 })
35 }
36
37 tracker := NewCommandRequestTracker()
38 const generation = uint64(1)
39 outcome, _ := tracker.Track(request.RequestID, generation)
40 commandCtx := d.serveCtx
41 if commandCtx == nil {
42 commandCtx = context.Background()
43 }
44 commandCtx, cancel := context.WithCancel(commandCtx)
45 defer cancel()
46 go func() {
47 result := d.dispatchCommand(commandCtx, request)
48 result.RequestID = request.RequestID
49 tracker.Complete(generation, result)
50 }()
51 commandClock := d.clock
52 if commandClock == nil {
53 commandClock = systemClock{}
54 }
55 result, waitErr := tracker.Wait(commandCtx, commandClock, request.RequestID, generation, outcome)
56 if waitErr != nil {
57 return d.sendCommandResult(tr, protocol.CommandResult{
58 RequestID: request.RequestID,
59 Outcome: protocol.CommandOutcomeUnknown,
60 Text: waitErr.Error(),
61 })
62 }
63 return d.sendCommandResult(tr, result)
64}
65
66// sendCommandResult gives one-shot control responses the same observable
67// transport-failure behavior regardless of which validation path produced it.
68func (d *Daemon) sendCommandResult(tr ports.ServerConnection, result protocol.CommandResult) error {
69 if err := d.boundedControlSend(tr, serverCommandResult(result)); err != nil {
70 d.log.Warn("command response send failed", "err", err)
71 return err
72 }
73 // One-shot control connections terminate after their refusal or
74 // result: closing here lets malformed first frames observe EOF
75 // instead of hanging on a half-open connection.
76 _ = tr.Close()
77 return nil
78}
79
80// boundedControlSend keeps one-shot control handlers from waiting forever on a
81// client that stopped reading. A timeout closes the exact transport so a
82// blocked Send can unwind before the handler returns.
83func (d *Daemon) boundedControlSend(tr ports.ServerConnection, message protocol.ServerMessage) error {
84 _, err := d.boundedSendWith(tr, func() error { return tr.SendServer(message) })
85 if errors.Is(err, errSendTimedOut) {
86 _ = tr.Close()
87 }
88 return err
89}
90
91// logKillResultSendFailure records that one one-shot kill result could not be
92// delivered. The kill path answers with an explicit correlated KillResult, so a
93// failed result send leaves the client at outcome-unknown rather than letting it
94// infer success from a silent close; the outcome and code fields preserve what
95// the client never observed.
96func (d *Daemon) logKillResultSendFailure(result protocol.KillResult, err error) {
97 if err != nil {
98 d.log.Warn("control error response send failed", "operation", "kill result", "outcome", result.Outcome, "code", result.Code, "err", err)
99 }
100}
101
102func serverCommandResult(result protocol.CommandResult) protocol.CommandResult { return result }
103
104func (d *Daemon) dispatchCommand(ctx context.Context, request protocol.CommandRequest) protocol.CommandResult {
105 cmd, ok := command.BySlug(request.Slug)
106 if !ok {
107 return commandFailure(protocol.ErrUnknownCommand, "unknown command: "+request.Slug)
108 }
109 if !cmd.Scriptable || cmd.Control == nil {
110 return commandFailure(protocol.ErrNotScriptable, request.Slug+" requires an attached client")
111 }
112 if cmd.Target == command.TargetNone && !request.Self {
113 return d.runControl(cmd, controlExec{ctx: ctx, d: d, recoveryName: request.TargetSession}, request)
114 }
115
116 sess, code, text := d.resolveTargetSession(request)
117 if sess == nil {
118 return commandFailure(code, text)
119 }
120 if cmd.Scope == command.CommandScopeCrossSession {
121 // Cross-session moves intentionally skip sess.dispatchMu: movePane and
122 // moveTab acquire both session dispatch locks through lockMoveDispatch in
123 // global order. Pre-locking the source here could recreate the
124 // opposite-direction deadlock covered by
125 // TestHandleCommandOppositeMoveCommandsDoNotDeadlock.
126 tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request)
127 if code != 0 {
128 return commandFailure(code, text)
129 }
130 return d.runControl(cmd, controlExec{ctx: ctx, d: d, sess: sess, tab: tb, target: daemonActionTarget{session: sess, tab: tb, pane: pane}}, request)
131 }
132
133 for {
134 select {
135 case <-ctx.Done():
136 return commandFailure(protocol.ErrInternal, ctx.Err().Error())
137 default:
138 }
139 if sess.dispatchMu.TryLock() {
140 break
141 }
142 runtime.Gosched()
143 }
144 defer sess.dispatchMu.Unlock()
145
146 tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request)
147 if code != 0 {
148 return commandFailure(code, text)
149 }
150 return d.runControl(cmd, controlExec{ctx: ctx, d: d, sess: sess, tab: tb, target: daemonActionTarget{session: sess, tab: tb, pane: pane}}, request)
151}
152
153func (d *Daemon) runControl(cmd command.Command, exec controlExec, request protocol.CommandRequest) protocol.CommandResult {
154 result, err := cmd.Control(exec, request.Args, command.ControlOptions{JSON: request.JSON})
155 if err == nil {
156 return protocol.CommandResult{Outcome: protocol.CommandSucceeded, Output: result.Output}
157 }
158 switch {
159 case errors.Is(err, command.ErrInvalidArguments), errors.Is(err, errSessionNameRequired), errors.Is(err, domain.ErrInvalidSessionName):
160 return commandFailure(protocol.ErrInvalidCommandArgs, "usage: "+cmd.Usage)
161 case errors.Is(err, errSessionNameInUse):
162 return commandFailure(protocol.ErrNameTaken, err.Error())
163 case errors.Is(err, errPurgeAdmissionClosed):
164 // A KillAll purge owns the exact lifecycle set. The operation is
165 // transient (the daemon is not shutting down), so surface a retryable
166 // internal failure rather than a name or shutdown verdict.
167 return commandFailure(protocol.ErrInternal, err.Error())
168 case errors.Is(err, errSessionKillParticipantsChanged):
169 // A concurrent detach or token resume invalidated the teardown snapshot
170 // before it removed anything. The operation is safe to retry, so report
171 // that clear outcome instead of the internal sentinel text.
172 return commandFailure(protocol.ErrInternal, sessionKillRetryMessage)
173 case isMoveCommandError(err):
174 return moveCommandFailure(err)
175 case errors.Is(err, layout.ErrNotInSplit):
176 return commandFailure(protocol.ErrNoSuchTarget, "pane is not in a split")
177 case errors.Is(err, layout.ErrTooSmall):
178 return commandFailure(protocol.ErrNoSuchTarget, "pane cannot be resized further")
179 default:
180 return commandFailure(protocol.ErrInternal, err.Error())
181 }
182}
183
184func commandFailure(code uint16, text string) protocol.CommandResult {
185 return protocol.CommandResult{Outcome: protocol.CommandFailed, Code: code, Text: text}
186}
187
188// resolveTargetSession applies explicit-name, stable-ID, then unique-session
189// resolution. A name paired with complete stable IDs is always advisory because
190// a process keeps its original VEV session component after relocation.
191func (d *Daemon) resolveTargetSession(request protocol.CommandRequest) (*session, uint16, string) {
192 d.mu.Lock()
193 defer d.mu.Unlock()
194
195 named := d.findByNameLocked(request.TargetSession)
196 if request.Self && (request.TargetTab == "" || request.TargetPane == "") {
197 return nil, protocol.ErrNoSuchTarget, "--self requires target tab and pane IDs"
198 }
199 if request.TargetSession != "" && request.TargetTab == "" && request.TargetPane == "" {
200 if named == nil {
201 return nil, protocol.ErrNoSuchTarget, "no such session: " + request.TargetSession
202 }
203 return named, 0, ""
204 }
205 if request.TargetTab != "" || request.TargetPane != "" {
206 if request.TargetTab == "" || request.TargetPane == "" {
207 return nil, protocol.ErrNoSuchTarget, "target tab and pane IDs must be provided together"
208 }
209 var match *session
210 for _, sess := range d.sessions {
211 if sess == nil || !sess.containsStableIDs(request.TargetTab, request.TargetPane) {
212 continue
213 }
214 if match != nil {
215 return nil, protocol.ErrAmbiguousTarget, "target tab and pane IDs are ambiguous"
216 }
217 match = sess
218 }
219 if match != nil {
220 return match, 0, ""
221 }
222 return nil, protocol.ErrNoSuchTarget, "no live session contains the target tab/pane"
223 }
224 locals := sessionsSnapshot(d.sessions)
225 if len(locals) == 0 {
226 return nil, protocol.ErrNoSuchTarget, "no live sessions"
227 }
228 if len(locals) != 1 {
229 return nil, protocol.ErrAmbiguousTarget, "several sessions are live; use -s <session> or run from inside a pane"
230 }
231 return locals[0], 0, ""
232}
233
234func (s *session) containsStableIDs(tabID, paneID string) bool {
235 s.mu.Lock()
236 tabs := append([]*tab(nil), s.tabs...)
237 s.mu.Unlock()
238 for _, tb := range tabs {
239 tb.mu.Lock()
240 pane := paneByStableIDLocked(tb, paneID)
241 match := tb.stableID == tabID && pane != nil
242 tb.mu.Unlock()
243 if match {
244 return true
245 }
246 }
247 return false
248}
249
250func resolveControlTarget(sess *session, kind command.TargetKind, request protocol.CommandRequest) (*tab, *pane, uint16, string) {
251 sess.mu.Lock()
252 tabs := append([]*tab(nil), sess.tabs...)
253 sess.mu.Unlock()
254 if request.Self {
255 for _, tb := range tabs {
256 tb.mu.Lock()
257 if tb.stableID != request.TargetTab {
258 tb.mu.Unlock()
259 continue
260 }
261 target := paneByStableIDLocked(tb, request.TargetPane)
262 tb.mu.Unlock()
263 if target == nil {
264 return nil, nil, protocol.ErrNoSuchTarget, "target pane does not belong to target tab"
265 }
266 return tb, target, 0, ""
267 }
268 return nil, nil, protocol.ErrNoSuchTarget, "no such target tab"
269 }
270 if len(tabs) == 0 {
271 return nil, nil, protocol.ErrNoSuchTarget, "target session has no tabs"
272 }
273 tb := tabs[0]
274 if kind != command.TargetPane {
275 return tb, nil, 0, ""
276 }
277 tb.mu.Lock()
278 target := tb.focusedPane()
279 tb.mu.Unlock()
280 if target == nil {
281 return nil, nil, protocol.ErrNoSuchTarget, "no such target pane"
282 }
283 return tb, target, 0, ""
284}
285
286func paneByStableIDLocked(tb *tab, stableID string) *pane {
287 for _, pane := range tb.panes {
288 if pane.stableID == stableID {
289 return pane
290 }
291 }
292 return nil
293}
294
295type daemonActionKind uint8
296
297const (
298 daemonActionCreateTab daemonActionKind = iota
299 daemonActionCloseTab
300 daemonActionSplitPane
301 daemonActionStackPane
302 daemonActionToggleStack
303 daemonActionClosePane
304 daemonActionFocusPane
305 daemonActionNextTab
306 daemonActionPreviousTab
307 daemonActionRenameSession
308 daemonActionRenameTab
309 daemonActionResizePane
310 daemonActionEqualizePanes
311 daemonActionConsumeOrExpelPane
312)
313
314type daemonActionTarget struct {
315 session *session
316 attachment *attachedClient
317 tab *tab
318 pane *pane
319}
320
321type daemonActionRequest struct {
322 kind daemonActionKind
323 target daemonActionTarget
324 effect *attachmentEffect
325 direction layout.Direction
326 axis layout.Axis
327 delta int
328 viewport domain.Size
329 name string
330}
331
332type daemonActionRunner interface {
333 Run(daemonActionRequest) error
334}
335
336func resolveDaemonActionTargetForAttachment(sess *session, ac *attachedClient) daemonActionTarget {
337 if sess == nil {
338 return daemonActionTarget{}
339 }
340 if ac != nil {
341 tb, pane := sess.paneForAttachment(ac)
342 if tb == nil {
343 return daemonActionTarget{session: sess, attachment: ac}
344 }
345 return daemonActionTarget{session: sess, attachment: ac, tab: tb, pane: pane}
346 }
347 tb := sess.firstTab()
348 if tb == nil {
349 return daemonActionTarget{session: sess}
350 }
351 tb.mu.Lock()
352 pane := tb.focusedPane()
353 tb.mu.Unlock()
354 return daemonActionTarget{session: sess, tab: tb, pane: pane}
355}
356
357// daemonActions is the daemon-owned mutation seam shared by palette and
358// control. Requests contain already-resolved targets and no attachment state;
359// adapters remain responsible for their attached-client UI lifecycle.
360type daemonActions struct{ d *Daemon }
361
362func (a daemonActions) Run(request daemonActionRequest) error {
363 target := request.target
364 switch request.kind {
365 case daemonActionCreateTab:
366 return a.d.createTabForAttachment(target.session, target.attachment, request.viewport)
367 case daemonActionCloseTab:
368 return a.d.closeTabLockedWithEffect(target.session, target.tab, true, request.effect)
369 case daemonActionSplitPane:
370 change, err := a.d.splitPaneAt(target.session, target.tab, target.pane, request.direction)
371 if err == nil {
372 publishPaneFocusForAttachment(target.session, target.attachment, change)
373 }
374 return err
375 case daemonActionStackPane:
376 change, err := a.d.stackPaneAt(target.session, target.tab, target.pane)
377 if err == nil {
378 publishPaneFocusForAttachment(target.session, target.attachment, change)
379 }
380 return err
381 case daemonActionToggleStack:
382 return a.d.toggleStackAt(target.session, target.tab, target.pane)
383 case daemonActionClosePane:
384 if a.d.ptys == nil {
385 return nil
386 }
387 if !a.d.hasDaemonActionPaneTarget(target) {
388 return layout.ErrNotFound
389 }
390 if err := a.d.closePaneLockedWithEffect(target.session, target.tab, target.pane.id, nil, true, request.effect); err != nil {
391 return err
392 }
393 if a.d.hasDaemonActionPaneTarget(target) {
394 return errors.New("pane close did not complete")
395 }
396 return nil
397 case daemonActionFocusPane:
398 change, err := a.d.focusDirAt(target.session, target.tab, target.pane, request.direction)
399 if err == nil {
400 publishPaneFocusForAttachment(target.session, target.attachment, change)
401 }
402 return err
403 case daemonActionNextTab:
404 return a.switchRelative(target.session, target.attachment, 1)
405 case daemonActionPreviousTab:
406 return a.switchRelative(target.session, target.attachment, -1)
407 case daemonActionRenameSession:
408 return a.d.renameSession(target.session, request.name)
409 case daemonActionRenameTab:
410 return a.d.renameTab(target.session, target.tab, request.name)
411 case daemonActionResizePane:
412 return a.d.resizePane(target, request.axis, request.delta)
413 case daemonActionEqualizePanes:
414 return a.d.equalizePanes(target)
415 case daemonActionConsumeOrExpelPane:
416 return a.d.consumeOrExpelPane(target, request.direction)
417 default:
418 return errors.New("daemon: unknown action")
419 }
420}
421
422// hasDaemonActionPaneTarget verifies that an already-resolved pane still
423// belongs to the live tab and session. Close-pane uses it both before mutation
424// and as a postcondition because closing a final pane delegates to close-tab.
425func (d *Daemon) hasDaemonActionPaneTarget(target daemonActionTarget) bool {
426 if target.session == nil || target.tab == nil || target.pane == nil {
427 return false
428 }
429 d.mu.Lock()
430 defer d.mu.Unlock()
431 if d.sessions[target.session.id] != target.session {
432 return false
433 }
434 target.session.mu.Lock()
435 defer target.session.mu.Unlock()
436 if !slices.Contains(target.session.tabs, target.tab) {
437 return false
438 }
439 target.tab.mu.Lock()
440 defer target.tab.mu.Unlock()
441 return target.tab.panes[target.pane.id] == target.pane &&
442 target.tab.tree != nil && layout.ContainsLeaf(target.tab.tree.Root, target.pane.id)
443}
444
445func (a daemonActions) switchRelative(sess *session, ac *attachedClient, delta int) error {
446 if sess.switchAttachmentRelativeForDispatch(ac, delta) {
447 a.d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(ac), false, ac, nil)
448 }
449 return nil
450}
451
452// controlExec implements command.ControlContext against resolved daemon-owned
453// targets. It deliberately contains no attached-client state.
454type controlExec struct {
455 ctx context.Context
456 d *Daemon
457 sess *session
458 tab *tab
459 recoveryName string
460 target daemonActionTarget
461 actions daemonActionRunner
462}
463
464func (e controlExec) runAction(request daemonActionRequest) error {
465 if request.target.session == nil {
466 request.target = e.target
467 }
468 _, err := e.d.dispatchAction(daemonActionDispatch{runner: e.actions, request: request, producer: "control.go"})
469 return err
470}
471
472// daemonActionDispatch describes one action started by any initiating path
473// (palette, control command, key binding, or resize mode).
474type daemonActionDispatch struct {
475 // runner performs the mutation; nil selects the daemon's own actions.
476 runner daemonActionRunner
477 request daemonActionRequest
478 // serialize, when non-nil, runs target resolution and the mutation under
479 // that session's dispatch boundary. Callers already inside it leave it nil.
480 serialize *session
481 // resolveFrom and attachment resolve an unset target from the attachment's
482 // current view; attachment also receives the follow-up render.
483 resolveFrom *session
484 attachment *attachedClient
485 producer string
486}
487
488// dispatchAction runs one daemon action through its whole lifecycle: target
489// resolution, the mutation, no-change normalization, and the attached
490// client's follow-up render. With serialize set, the render runs after the
491// dispatch boundary is released; otherwise it runs inside the caller's
492// boundary (palette and control paths). It reports whether the action
493// changed anything; errDaemonActionNoChange is never returned.
494func (d *Daemon) dispatchAction(x daemonActionDispatch) (bool, error) {
495 runner := x.runner
496 if runner == nil {
497 runner = daemonActions{d: d}
498 }
499 request := x.request
500 run := func() error {
501 if request.target.session == nil && x.resolveFrom != nil {
502 request.target = resolveDaemonActionTargetForAttachment(x.resolveFrom, x.attachment)
503 }
504 return runner.Run(request)
505 }
506 var err error
507 if x.serialize != nil {
508 err = x.serialize.runMutation(run)
509 } else {
510 err = run()
511 }
512 if errors.Is(err, errDaemonActionNoChange) {
513 return false, nil
514 }
515 if err != nil {
516 return false, err
517 }
518 ac := x.attachment
519 if ac == nil {
520 ac = request.target.attachment
521 }
522 finishDaemonActionForClient(d, request, ac, x.producer)
523 return true, nil
524}
525
526func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *attachedClient, producer string) {
527 if d == nil || ac == nil {
528 return
529 }
530 if request.kind == daemonActionFocusPane && request.target.tab != nil && request.target.pane != nil {
531 d.finishPaneFocusForClient(request.target.session, ac, request.target.tab, request.target.pane.id, producer)
532 return
533 }
534 d.invalidateRender(request.target.session, ac, true, producer)
535}
536
537func (e controlExec) CreateTab() error {
538 return e.runAction(daemonActionRequest{kind: daemonActionCreateTab, viewport: e.sess.fullViewportSize()})
539}
540func (e controlExec) CreateSessionNamed(name string) error {
541 if err := domain.ValidateSessionName(name); err != nil {
542 return command.ErrInvalidArguments
543 }
544 e.sess.mu.Lock()
545 cwd := e.sess.cwd
546 env, provisional := e.sess.environmentSeedLocked()
547 e.sess.mu.Unlock()
548 geometry := domain.Geometry{Size: e.sess.fullViewportSize()}
549 if source, ok := e.sess.geometry.sourceSnapshot(e.sess); ok {
550 geometry = source.geometry
551 }
552 e.d.mu.Lock()
553 defer e.d.mu.Unlock()
554 if e.d.closing {
555 return errors.New("daemon is shutting down")
556 }
557 if e.d.purgeAdmissionClosedLocked() {
558 return errPurgeAdmissionClosed
559 }
560 if e.d.nameLiveOrStoppedLocked(name) {
561 return errSessionNameInUse
562 }
563 created, err := e.d.createSessionLocked(name, false, cwd, geometry, env)
564 if err == nil && provisional {
565 markEnvironmentProvisional(created)
566 }
567 return err
568}
569func (e controlExec) CloseTab() error {
570 return e.runAction(daemonActionRequest{kind: daemonActionCloseTab})
571}
572func (e controlExec) ClosePane() error {
573 return e.runAction(daemonActionRequest{kind: daemonActionClosePane})
574}
575
576func (e controlExec) MovePane(destinationSession, destinationTabID string) error {
577 destination := e.d.liveSessionByName(destinationSession)
578 if destination == nil {
579 return errMoveStaleTarget
580 }
581 if e.sess == nil || e.tab == nil || e.target.pane == nil {
582 return errMovePaneInvalid
583 }
584 attachment := e.target.attachment
585 token := attachmentCapability{}
586 if attachment != nil {
587 token = e.sess.captureAttachmentCapability(attachment, attachment.transport())
588 if token.ac == nil {
589 return errMoveStaleTarget
590 }
591 }
592 return e.d.movePane(movePaneRequest{
593 Attachment: attachment,
594 AttachmentCapability: token,
595 Source: sessionMoveLocator(e.sess),
596 SourceTabID: domain.TabStableID(e.tab.stableID),
597 SourcePaneID: domain.PaneStableID(e.target.pane.stableID),
598 Destination: sessionMoveLocator(destination),
599 DestinationTabID: domain.TabStableID(destinationTabID),
600 })
601}
602
603func (e controlExec) MoveTab(destinationSession string) error {
604 destination := e.d.liveSessionByName(destinationSession)
605 if destination == nil {
606 return errMoveStaleTarget
607 }
608 if e.sess == nil || e.tab == nil {
609 return errMovePaneInvalid
610 }
611 attachment := e.target.attachment
612 token := attachmentCapability{}
613 if attachment != nil {
614 token = e.sess.captureAttachmentCapability(attachment, attachment.transport())
615 if token.ac == nil {
616 return errMoveStaleTarget
617 }
618 }
619 return e.d.moveTab(moveTabRequest{
620 Attachment: attachment,
621 AttachmentCapability: token,
622 Source: sessionMoveLocator(e.sess),
623 SourceTabID: domain.TabStableID(e.tab.stableID),
624 Destination: sessionMoveLocator(destination),
625 })
626}
627
628func (d *Daemon) liveSessionByName(name string) *session {
629 d.mu.Lock()
630 defer d.mu.Unlock()
631 return d.findByNameLocked(name)
632}
633
634func sessionMoveLocator(sess *session) moveSessionLocator {
635 sess.mu.Lock()
636 name := sess.name
637 sess.mu.Unlock()
638 return moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation, Name: name}
639}
640func (e controlExec) split(direction layout.Direction) error {
641 return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction})
642}
643func (e controlExec) SplitRight() error { return e.split(layout.Right) }
644func (e controlExec) SplitLeft() error { return e.split(layout.Left) }
645func (e controlExec) SplitUp() error { return e.split(layout.Up) }
646func (e controlExec) SplitDown() error { return e.split(layout.Down) }
647func (e controlExec) consumeOrExpelPane(direction layout.Direction) error {
648 return e.runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: direction})
649}
650func (e controlExec) ConsumeOrExpelPaneLeft() error {
651 return e.consumeOrExpelPane(layout.Left)
652}
653func (e controlExec) ConsumeOrExpelPaneRight() error {
654 return e.consumeOrExpelPane(layout.Right)
655}
656func (e controlExec) StackPane() error {
657 return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
658}
659func (e controlExec) ToggleStack() error {
660 return e.runAction(daemonActionRequest{kind: daemonActionToggleStack})
661}
662func (e controlExec) GrowPaneWidth() error { return e.resize(layout.Width, resizeStepCols) }
663func (e controlExec) ShrinkPaneWidth() error { return e.resize(layout.Width, -resizeStepCols) }
664func (e controlExec) GrowPaneHeight() error { return e.resize(layout.Height, resizeStepRows) }
665func (e controlExec) ShrinkPaneHeight() error { return e.resize(layout.Height, -resizeStepRows) }
666func (e controlExec) resize(axis layout.Axis, delta int) error {
667 return e.runAction(daemonActionRequest{kind: daemonActionResizePane, axis: axis, delta: delta})
668}
669func (e controlExec) EqualizePanes() error {
670 return resizeUserError(e.runAction(daemonActionRequest{kind: daemonActionEqualizePanes}))
671}
672func (e controlExec) focus(direction layout.Direction) error {
673 return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})
674}
675func (e controlExec) FocusPaneLeft() error { return e.focus(layout.Left) }
676func (e controlExec) FocusPaneRight() error { return e.focus(layout.Right) }
677func (e controlExec) FocusPaneUp() error { return e.focus(layout.Up) }
678func (e controlExec) FocusPaneDown() error { return e.focus(layout.Down) }
679func (e controlExec) NextTab() error {
680 return e.runAction(daemonActionRequest{kind: daemonActionNextTab})
681}
682func (e controlExec) PrevTab() error {
683 return e.runAction(daemonActionRequest{kind: daemonActionPreviousTab})
684}
685func (e controlExec) RenameSessionTo(name string) error {
686 return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name})
687}
688func (e controlExec) RenameTabTo(name string) error {
689 return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, name: name})
690}
691func (e controlExec) Toast(severity, message string) error {
692 var level domain.NoticeSeverity
693 switch severity {
694 case "info":
695 level = domain.NoticeInfo
696 case "warn":
697 level = domain.NoticeWarn
698 case "error":
699 level = domain.NoticeError
700 default:
701 return command.ErrInvalidArguments
702 }
703 if strings.TrimSpace(message) == "" {
704 return command.ErrInvalidArguments
705 }
706 e.d.notify(e.sess, level, domain.NoticeUser, message, nil)
707 return nil
708}
709
710func (e controlExec) SessionRecovery(action string) (string, error) {
711 if e.d == nil || e.d.recovery == nil || e.recoveryName == "" || action != "discard" {
712 return "", command.ErrInvalidArguments
713 }
714 ctx := e.ctx
715 if ctx == nil {
716 ctx = e.d.serveCtx
717 }
718 if ctx == nil {
719 ctx = context.Background()
720 }
721 if err := e.d.recovery.Discard(ctx, e.recoveryName); err != nil {
722 return "", err
723 }
724 record, ok, err := e.d.catalogue.Record(e.recoveryName)
725 if err != nil {
726 return "", err
727 }
728 if ok {
729 e.d.setStoppedRecovery(record, protocol.SessionDown)
730 }
731 return "", nil
732}
733
734func (e controlExec) ListSessions(asJSON bool) (string, error) {
735 type row struct {
736 Name string `json:"name"`
737 Ephemeral bool `json:"ephemeral"`
738 Tabs int `json:"tabs"`
739 Attached bool `json:"attached"`
740 Active bool `json:"active"`
741 }
742 e.d.mu.Lock()
743 sessions := sessionsSnapshot(e.d.sessions)
744 e.d.mu.Unlock()
745 snaps := make([]sessionView, 0, len(sessions))
746 for _, sess := range sessions {
747 snaps = append(snaps, sess.snapshotView(viewOptions{}))
748 }
749 activeIdx := -1
750 for i, snap := range snaps {
751 if activeIdx == -1 || snap.mruAt > snaps[activeIdx].mruAt {
752 activeIdx = i
753 }
754 }
755 rows := make([]row, 0, len(snaps))
756 for i, snap := range snaps {
757 rows = append(rows, row{Name: snap.name, Ephemeral: snap.ephemeral, Tabs: snap.tabCount, Attached: snap.attached, Active: i == activeIdx})
758 }
759 sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name })
760 if asJSON {
761 return marshalListing(rows)
762 }
763 var out strings.Builder
764 out.WriteString("NAME\tSTATE\tTABS\tATTACHED\tACTIVE\n")
765 for _, row := range rows {
766 state := "named"
767 if row.Ephemeral {
768 state = "ephemeral"
769 }
770 fmt.Fprintf(&out, "%s\t%s\t%d\t%t\t%t\n", row.Name, state, row.Tabs, row.Attached, row.Active)
771 }
772 return out.String(), nil
773}
774
775func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
776 if !asJSON {
777 return "", command.ErrInvalidArguments
778 }
779 // The catalogue export is a local-only projection: it
780 // reads the remote-free capture and never the remote directory, so a
781 // serving daemon's own catalogue is independent of remote monitoring.
782 inv := e.d.captureLocalSessionInventory(viewOptions{tabDetails: true, focusedTitles: true}, true)
783 stopped := inv.visibleStopped()
784
785 rows := make([]catalogue.RemoteCatalogSession, 0, len(inv.live)+len(stopped))
786 liveNames := make(map[string]struct{}, len(inv.live))
787 for _, item := range inv.live {
788 snap := item.view
789 tabs, err := remoteCatalogTabs(snap)
790 if err != nil {
791 return "", err
792 }
793 row := catalogue.RemoteCatalogSession{
794 LifecycleID: snap.incarnation,
795 Name: snap.name,
796 State: catalogue.RemoteCatalogSessionUp,
797 Ephemeral: snap.ephemeral,
798 Tabs: tabs,
799 Attached: snap.attached,
800 LastUsedSeq: snap.mruAt,
801 }
802 if len(tabs) != 0 {
803 row.ActiveTabID = tabs[min(snap.defaultTab, len(tabs)-1)].ID
804 }
805 rows = append(rows, row)
806 liveNames[row.Name] = struct{}{}
807 }
808 for _, entry := range stopped {
809 if _, live := liveNames[entry.name]; live {
810 continue
811 }
812 tabs, err := remoteCatalogStoppedTabs(entry)
813 if err != nil {
814 return "", err
815 }
816 state := catalogue.RemoteCatalogSessionDown
817 reason := ""
818 if entry.broken() {
819 state = catalogue.RemoteCatalogSessionBroken
820 reason = domain.RemoteReasonSessionBroken
821 }
822 rows = append(rows, catalogue.RemoteCatalogSession{
823 LifecycleID: entry.incarnation,
824 Name: entry.name,
825 State: state,
826 Ephemeral: false,
827 Tabs: tabs,
828 LastUsedSeq: entry.lastUsedSeq,
829 Reason: reason,
830 })
831 }
832 sort.Slice(rows, func(i, j int) bool {
833 if rows[i].Name != rows[j].Name {
834 return rows[i].Name < rows[j].Name
835 }
836 return rows[i].LifecycleID.String() < rows[j].LifecycleID.String()
837 })
838 catalog := catalogue.RemoteCatalog{
839 ProtocolVersion: protocol.Version,
840 SchemaVersion: catalogue.RemoteCatalogSchemaVersion,
841 Sessions: rows,
842 }
843 if err := catalogue.ValidateRemoteCatalog(catalog); err != nil {
844 return "", err
845 }
846 return marshalListing(catalog)
847}
848
849func remoteCatalogTabs(view sessionView) ([]catalogue.RemoteCatalogTab, error) {
850 if len(view.tabs) > catalogue.RemoteCatalogMaxTabsPerSess {
851 return nil, fmt.Errorf("remote catalog: session %q has too many tabs", view.name)
852 }
853 tabs := make([]catalogue.RemoteCatalogTab, 0, len(view.tabs))
854 for i, tab := range view.tabs {
855 tabs = append(tabs, catalogue.RemoteCatalogTab{
856 ID: string(tab.id),
857 Index: uint16(i),
858 Name: tab.name,
859 Detail: tabTitleDetail(tab.name, tab.focusedTitle),
860 Attention: tab.attention,
861 })
862 }
863 return tabs, nil
864}
865
866func remoteCatalogStoppedTabs(entry inactiveSession) ([]catalogue.RemoteCatalogTab, error) {
867 records := entry.tabRecords
868 if len(records) == 0 && len(entry.tabNames) != 0 {
869 records = make([]domain.CatalogueTabRecord, len(entry.tabNames))
870 for i, name := range entry.tabNames {
871 records[i].Name = name
872 }
873 }
874 if len(records) > catalogue.RemoteCatalogMaxTabsPerSess {
875 return nil, fmt.Errorf("remote catalog: session %q has too many tabs", entry.name)
876 }
877 tabs := make([]catalogue.RemoteCatalogTab, 0, len(records))
878 for i, record := range records {
879 tabs = append(tabs, catalogue.RemoteCatalogTab{ID: string(record.StableID), Index: uint16(i), Name: record.Name})
880 }
881 return tabs, nil
882}
883
884func (e controlExec) ListTabs(asJSON bool) (string, error) {
885 type row struct {
886 Index int `json:"index"`
887 ID string `json:"id"`
888 Name string `json:"name"`
889 Panes int `json:"panes"`
890 Active bool `json:"active"`
891 }
892 e.sess.mu.Lock()
893 tabs := append([]*tab(nil), e.sess.tabs...)
894 e.sess.mu.Unlock()
895 active, _ := e.sess.tabIndexForAttachment(e.target.attachment)
896 rows := make([]row, 0, len(tabs))
897 for i, tb := range tabs {
898 tb.mu.Lock()
899 rows = append(rows, row{Index: i, ID: tb.stableID, Name: tb.name, Panes: len(tb.panes), Active: i == active})
900 tb.mu.Unlock()
901 }
902 if asJSON {
903 return marshalListing(rows)
904 }
905 var out strings.Builder
906 out.WriteString("INDEX\tID\tNAME\tPANES\tACTIVE\n")
907 for _, row := range rows {
908 fmt.Fprintf(&out, "%d\t%s\t%s\t%d\t%t\n", row.Index, row.ID, row.Name, row.Panes, row.Active)
909 }
910 return out.String(), nil
911}
912
913// SessionEnvironment exports the session-bound variables that future PTY
914// children of the target session receive, so running shells can catch up.
915func (e controlExec) SessionEnvironment(asJSON bool) (string, error) {
916 e.sess.mu.Lock()
917 env := e.sess.env
918 e.sess.mu.Unlock()
919 return sessionEnvironmentExport(env, asJSON)
920}
921
922func (e controlExec) ListPanes(asJSON bool) (string, error) {
923 type row struct {
924 ID string `json:"id"`
925 Pane string `json:"pane"`
926 Size string `json:"size"`
927 CWD string `json:"cwd"`
928 Focused bool `json:"focused"`
929 }
930 tb := e.tab
931 if tb == nil {
932 if e.target.attachment == nil {
933 tb = e.sess.firstTab()
934 } else {
935 tb = e.sess.tabForAttachment(e.target.attachment)
936 }
937 }
938 if tb == nil {
939 return "", errors.New("target session has no active tab")
940 }
941 e.sess.mu.Lock()
942 fallbackCWD := e.sess.cwd
943 e.sess.mu.Unlock()
944 var focus layout.PaneID
945 var focusStableID domain.PaneStableID
946 if e.target.attachment != nil {
947 focusStableID = e.target.attachment.viewSnapshot().paneID
948 }
949 tb.mu.Lock()
950 if e.target.attachment == nil {
951 if focused := tb.focusedPane(); focused != nil {
952 focus = focused.id
953 }
954 }
955 panes := tb.panesSnapshot()
956 tb.mu.Unlock()
957 rows := make([]row, 0, len(panes))
958 for _, pane := range panes {
959 pane.mu.Lock()
960 size := domain.Size{Cols: pane.rect.Width, Rows: pane.rect.Height}
961 pid := 0
962 if pane.pty != nil {
963 pid = pane.pty.Pid()
964 }
965 pane.mu.Unlock()
966 cwd := fallbackCWD
967 if e.d.procCwd != nil && pid > 0 {
968 if live, err := e.d.procCwd(pid); err == nil && live != "" {
969 cwd = live
970 }
971 }
972 focused := pane.id == focus
973 if e.target.attachment != nil {
974 focused = domain.PaneStableID(pane.stableID) == focusStableID
975 }
976 rows = append(rows, row{ID: pane.stableID, Pane: string(pane.id), Size: fmt.Sprintf("%dx%d", size.Cols, size.Rows), CWD: cwd, Focused: focused})
977 }
978 sort.Slice(rows, func(i, j int) bool { return rows[i].Pane < rows[j].Pane })
979 if asJSON {
980 return marshalListing(rows)
981 }
982 var out strings.Builder
983 out.WriteString("ID\tPANE\tSIZE\tCWD\tFOCUSED\n")
984 for _, row := range rows {
985 fmt.Fprintf(&out, "%s\t%s\t%s\t%s\t%t\n", row.ID, row.Pane, row.Size, row.CWD, row.Focused)
986 }
987 return out.String(), nil
988}
989
990func marshalListing(value any) (string, error) {
991 encoded, err := json.Marshal(value)
992 if err != nil {
993 return "", err
994 }
995 return string(encoded) + "\n", nil
996}