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, env := e.sess.cwd, copyEnvironment(e.sess.env)
546 e.sess.mu.Unlock()
547 geometry := domain.Geometry{Size: e.sess.fullViewportSize()}
548 if source, ok := e.sess.geometry.sourceSnapshot(e.sess); ok {
549 geometry = source.geometry
550 }
551 e.d.mu.Lock()
552 defer e.d.mu.Unlock()
553 if e.d.closing {
554 return errors.New("daemon is shutting down")
555 }
556 if e.d.purgeAdmissionClosedLocked() {
557 return errPurgeAdmissionClosed
558 }
559 if e.d.nameLiveOrStoppedLocked(name) {
560 return errSessionNameInUse
561 }
562 _, err := e.d.createSessionLocked(name, false, cwd, geometry, env)
563 return err
564}
565func (e controlExec) CloseTab() error {
566 return e.runAction(daemonActionRequest{kind: daemonActionCloseTab})
567}
568func (e controlExec) ClosePane() error {
569 return e.runAction(daemonActionRequest{kind: daemonActionClosePane})
570}
571
572func (e controlExec) MovePane(destinationSession, destinationTabID string) error {
573 destination := e.d.liveSessionByName(destinationSession)
574 if destination == nil {
575 return errMoveStaleTarget
576 }
577 if e.sess == nil || e.tab == nil || e.target.pane == nil {
578 return errMovePaneInvalid
579 }
580 attachment := e.target.attachment
581 token := attachmentCapability{}
582 if attachment != nil {
583 token = e.sess.captureAttachmentCapability(attachment, attachment.transport())
584 if token.ac == nil {
585 return errMoveStaleTarget
586 }
587 }
588 return e.d.movePane(movePaneRequest{
589 Attachment: attachment,
590 AttachmentCapability: token,
591 Source: sessionMoveLocator(e.sess),
592 SourceTabID: domain.TabStableID(e.tab.stableID),
593 SourcePaneID: domain.PaneStableID(e.target.pane.stableID),
594 Destination: sessionMoveLocator(destination),
595 DestinationTabID: domain.TabStableID(destinationTabID),
596 })
597}
598
599func (e controlExec) MoveTab(destinationSession string) error {
600 destination := e.d.liveSessionByName(destinationSession)
601 if destination == nil {
602 return errMoveStaleTarget
603 }
604 if e.sess == nil || e.tab == nil {
605 return errMovePaneInvalid
606 }
607 attachment := e.target.attachment
608 token := attachmentCapability{}
609 if attachment != nil {
610 token = e.sess.captureAttachmentCapability(attachment, attachment.transport())
611 if token.ac == nil {
612 return errMoveStaleTarget
613 }
614 }
615 return e.d.moveTab(moveTabRequest{
616 Attachment: attachment,
617 AttachmentCapability: token,
618 Source: sessionMoveLocator(e.sess),
619 SourceTabID: domain.TabStableID(e.tab.stableID),
620 Destination: sessionMoveLocator(destination),
621 })
622}
623
624func (d *Daemon) liveSessionByName(name string) *session {
625 d.mu.Lock()
626 defer d.mu.Unlock()
627 return d.findByNameLocked(name)
628}
629
630func sessionMoveLocator(sess *session) moveSessionLocator {
631 sess.mu.Lock()
632 name := sess.name
633 sess.mu.Unlock()
634 return moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation, Name: name}
635}
636func (e controlExec) split(direction layout.Direction) error {
637 return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction})
638}
639func (e controlExec) SplitRight() error { return e.split(layout.Right) }
640func (e controlExec) SplitLeft() error { return e.split(layout.Left) }
641func (e controlExec) SplitUp() error { return e.split(layout.Up) }
642func (e controlExec) SplitDown() error { return e.split(layout.Down) }
643func (e controlExec) consumeOrExpelPane(direction layout.Direction) error {
644 return e.runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: direction})
645}
646func (e controlExec) ConsumeOrExpelPaneLeft() error {
647 return e.consumeOrExpelPane(layout.Left)
648}
649func (e controlExec) ConsumeOrExpelPaneRight() error {
650 return e.consumeOrExpelPane(layout.Right)
651}
652func (e controlExec) StackPane() error {
653 return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
654}
655func (e controlExec) ToggleStack() error {
656 return e.runAction(daemonActionRequest{kind: daemonActionToggleStack})
657}
658func (e controlExec) GrowPaneWidth() error { return e.resize(layout.Width, resizeStepCols) }
659func (e controlExec) ShrinkPaneWidth() error { return e.resize(layout.Width, -resizeStepCols) }
660func (e controlExec) GrowPaneHeight() error { return e.resize(layout.Height, resizeStepRows) }
661func (e controlExec) ShrinkPaneHeight() error { return e.resize(layout.Height, -resizeStepRows) }
662func (e controlExec) resize(axis layout.Axis, delta int) error {
663 return e.runAction(daemonActionRequest{kind: daemonActionResizePane, axis: axis, delta: delta})
664}
665func (e controlExec) EqualizePanes() error {
666 return resizeUserError(e.runAction(daemonActionRequest{kind: daemonActionEqualizePanes}))
667}
668func (e controlExec) focus(direction layout.Direction) error {
669 return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})
670}
671func (e controlExec) FocusPaneLeft() error { return e.focus(layout.Left) }
672func (e controlExec) FocusPaneRight() error { return e.focus(layout.Right) }
673func (e controlExec) FocusPaneUp() error { return e.focus(layout.Up) }
674func (e controlExec) FocusPaneDown() error { return e.focus(layout.Down) }
675func (e controlExec) NextTab() error {
676 return e.runAction(daemonActionRequest{kind: daemonActionNextTab})
677}
678func (e controlExec) PrevTab() error {
679 return e.runAction(daemonActionRequest{kind: daemonActionPreviousTab})
680}
681func (e controlExec) RenameSessionTo(name string) error {
682 return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name})
683}
684func (e controlExec) RenameTabTo(name string) error {
685 return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, name: name})
686}
687func (e controlExec) Toast(severity, message string) error {
688 var level domain.NoticeSeverity
689 switch severity {
690 case "info":
691 level = domain.NoticeInfo
692 case "warn":
693 level = domain.NoticeWarn
694 case "error":
695 level = domain.NoticeError
696 default:
697 return command.ErrInvalidArguments
698 }
699 if strings.TrimSpace(message) == "" {
700 return command.ErrInvalidArguments
701 }
702 e.d.notify(e.sess, level, domain.NoticeUser, message, nil)
703 return nil
704}
705
706func (e controlExec) SessionRecovery(action string) (string, error) {
707 if e.d == nil || e.d.recovery == nil || e.recoveryName == "" || action != "discard" {
708 return "", command.ErrInvalidArguments
709 }
710 ctx := e.ctx
711 if ctx == nil {
712 ctx = e.d.serveCtx
713 }
714 if ctx == nil {
715 ctx = context.Background()
716 }
717 if err := e.d.recovery.Discard(ctx, e.recoveryName); err != nil {
718 return "", err
719 }
720 record, ok, err := e.d.catalogue.Record(e.recoveryName)
721 if err != nil {
722 return "", err
723 }
724 if ok {
725 e.d.setStoppedRecovery(record, protocol.SessionDown)
726 }
727 return "", nil
728}
729
730func (e controlExec) ListSessions(asJSON bool) (string, error) {
731 type row struct {
732 Name string `json:"name"`
733 Ephemeral bool `json:"ephemeral"`
734 Tabs int `json:"tabs"`
735 Attached bool `json:"attached"`
736 Active bool `json:"active"`
737 }
738 e.d.mu.Lock()
739 sessions := sessionsSnapshot(e.d.sessions)
740 e.d.mu.Unlock()
741 snaps := make([]sessionView, 0, len(sessions))
742 for _, sess := range sessions {
743 snaps = append(snaps, sess.snapshotView(viewOptions{}))
744 }
745 activeIdx := -1
746 for i, snap := range snaps {
747 if activeIdx == -1 || snap.mruAt > snaps[activeIdx].mruAt {
748 activeIdx = i
749 }
750 }
751 rows := make([]row, 0, len(snaps))
752 for i, snap := range snaps {
753 rows = append(rows, row{Name: snap.name, Ephemeral: snap.ephemeral, Tabs: snap.tabCount, Attached: snap.attached, Active: i == activeIdx})
754 }
755 sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name })
756 if asJSON {
757 return marshalListing(rows)
758 }
759 var out strings.Builder
760 out.WriteString("NAME\tSTATE\tTABS\tATTACHED\tACTIVE\n")
761 for _, row := range rows {
762 state := "named"
763 if row.Ephemeral {
764 state = "ephemeral"
765 }
766 fmt.Fprintf(&out, "%s\t%s\t%d\t%t\t%t\n", row.Name, state, row.Tabs, row.Attached, row.Active)
767 }
768 return out.String(), nil
769}
770
771func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
772 if !asJSON {
773 return "", command.ErrInvalidArguments
774 }
775 // The catalogue export is a local-only projection: it
776 // reads the remote-free capture and never the remote directory, so a
777 // serving daemon's own catalogue is independent of remote monitoring.
778 inv := e.d.captureLocalSessionInventory(viewOptions{tabDetails: true, focusedTitles: true}, true)
779 stopped := inv.visibleStopped()
780
781 rows := make([]catalogue.RemoteCatalogSession, 0, len(inv.live)+len(stopped))
782 liveNames := make(map[string]struct{}, len(inv.live))
783 for _, item := range inv.live {
784 snap := item.view
785 tabs, err := remoteCatalogTabs(snap)
786 if err != nil {
787 return "", err
788 }
789 row := catalogue.RemoteCatalogSession{
790 LifecycleID: snap.incarnation,
791 Name: snap.name,
792 State: catalogue.RemoteCatalogSessionUp,
793 Ephemeral: snap.ephemeral,
794 Tabs: tabs,
795 Attached: snap.attached,
796 LastUsedSeq: snap.mruAt,
797 }
798 if len(tabs) != 0 {
799 row.ActiveTabID = tabs[min(snap.defaultTab, len(tabs)-1)].ID
800 }
801 rows = append(rows, row)
802 liveNames[row.Name] = struct{}{}
803 }
804 for _, entry := range stopped {
805 if _, live := liveNames[entry.name]; live {
806 continue
807 }
808 tabs, err := remoteCatalogStoppedTabs(entry)
809 if err != nil {
810 return "", err
811 }
812 state := catalogue.RemoteCatalogSessionDown
813 reason := ""
814 if entry.broken() {
815 state = catalogue.RemoteCatalogSessionBroken
816 reason = domain.RemoteReasonSessionBroken
817 }
818 rows = append(rows, catalogue.RemoteCatalogSession{
819 LifecycleID: entry.incarnation,
820 Name: entry.name,
821 State: state,
822 Ephemeral: false,
823 Tabs: tabs,
824 LastUsedSeq: entry.lastUsedSeq,
825 Reason: reason,
826 })
827 }
828 sort.Slice(rows, func(i, j int) bool {
829 if rows[i].Name != rows[j].Name {
830 return rows[i].Name < rows[j].Name
831 }
832 return rows[i].LifecycleID.String() < rows[j].LifecycleID.String()
833 })
834 catalog := catalogue.RemoteCatalog{
835 ProtocolVersion: protocol.Version,
836 SchemaVersion: catalogue.RemoteCatalogSchemaVersion,
837 Sessions: rows,
838 }
839 if err := catalogue.ValidateRemoteCatalog(catalog); err != nil {
840 return "", err
841 }
842 return marshalListing(catalog)
843}
844
845func remoteCatalogTabs(view sessionView) ([]catalogue.RemoteCatalogTab, error) {
846 if len(view.tabs) > catalogue.RemoteCatalogMaxTabsPerSess {
847 return nil, fmt.Errorf("remote catalog: session %q has too many tabs", view.name)
848 }
849 tabs := make([]catalogue.RemoteCatalogTab, 0, len(view.tabs))
850 for i, tab := range view.tabs {
851 tabs = append(tabs, catalogue.RemoteCatalogTab{
852 ID: string(tab.id),
853 Index: uint16(i),
854 Name: tab.name,
855 Detail: tabTitleDetail(tab.name, tab.focusedTitle),
856 Attention: tab.attention,
857 })
858 }
859 return tabs, nil
860}
861
862func remoteCatalogStoppedTabs(entry inactiveSession) ([]catalogue.RemoteCatalogTab, error) {
863 records := entry.tabRecords
864 if len(records) == 0 && len(entry.tabNames) != 0 {
865 records = make([]domain.CatalogueTabRecord, len(entry.tabNames))
866 for i, name := range entry.tabNames {
867 records[i].Name = name
868 }
869 }
870 if len(records) > catalogue.RemoteCatalogMaxTabsPerSess {
871 return nil, fmt.Errorf("remote catalog: session %q has too many tabs", entry.name)
872 }
873 tabs := make([]catalogue.RemoteCatalogTab, 0, len(records))
874 for i, record := range records {
875 tabs = append(tabs, catalogue.RemoteCatalogTab{ID: string(record.StableID), Index: uint16(i), Name: record.Name})
876 }
877 return tabs, nil
878}
879
880func (e controlExec) ListTabs(asJSON bool) (string, error) {
881 type row struct {
882 Index int `json:"index"`
883 ID string `json:"id"`
884 Name string `json:"name"`
885 Panes int `json:"panes"`
886 Active bool `json:"active"`
887 }
888 e.sess.mu.Lock()
889 tabs := append([]*tab(nil), e.sess.tabs...)
890 e.sess.mu.Unlock()
891 active, _ := e.sess.tabIndexForAttachment(e.target.attachment)
892 rows := make([]row, 0, len(tabs))
893 for i, tb := range tabs {
894 tb.mu.Lock()
895 rows = append(rows, row{Index: i, ID: tb.stableID, Name: tb.name, Panes: len(tb.panes), Active: i == active})
896 tb.mu.Unlock()
897 }
898 if asJSON {
899 return marshalListing(rows)
900 }
901 var out strings.Builder
902 out.WriteString("INDEX\tID\tNAME\tPANES\tACTIVE\n")
903 for _, row := range rows {
904 fmt.Fprintf(&out, "%d\t%s\t%s\t%d\t%t\n", row.Index, row.ID, row.Name, row.Panes, row.Active)
905 }
906 return out.String(), nil
907}
908
909func (e controlExec) ListPanes(asJSON bool) (string, error) {
910 type row struct {
911 ID string `json:"id"`
912 Pane string `json:"pane"`
913 Size string `json:"size"`
914 CWD string `json:"cwd"`
915 Focused bool `json:"focused"`
916 }
917 tb := e.tab
918 if tb == nil {
919 if e.target.attachment == nil {
920 tb = e.sess.firstTab()
921 } else {
922 tb = e.sess.tabForAttachment(e.target.attachment)
923 }
924 }
925 if tb == nil {
926 return "", errors.New("target session has no active tab")
927 }
928 e.sess.mu.Lock()
929 fallbackCWD := e.sess.cwd
930 e.sess.mu.Unlock()
931 var focus layout.PaneID
932 var focusStableID domain.PaneStableID
933 if e.target.attachment != nil {
934 focusStableID = e.target.attachment.viewSnapshot().paneID
935 }
936 tb.mu.Lock()
937 if e.target.attachment == nil {
938 if focused := tb.focusedPane(); focused != nil {
939 focus = focused.id
940 }
941 }
942 panes := tb.panesSnapshot()
943 tb.mu.Unlock()
944 rows := make([]row, 0, len(panes))
945 for _, pane := range panes {
946 pane.mu.Lock()
947 size := domain.Size{Cols: pane.rect.Width, Rows: pane.rect.Height}
948 pid := 0
949 if pane.pty != nil {
950 pid = pane.pty.Pid()
951 }
952 pane.mu.Unlock()
953 cwd := fallbackCWD
954 if e.d.procCwd != nil && pid > 0 {
955 if live, err := e.d.procCwd(pid); err == nil && live != "" {
956 cwd = live
957 }
958 }
959 focused := pane.id == focus
960 if e.target.attachment != nil {
961 focused = domain.PaneStableID(pane.stableID) == focusStableID
962 }
963 rows = append(rows, row{ID: pane.stableID, Pane: string(pane.id), Size: fmt.Sprintf("%dx%d", size.Cols, size.Rows), CWD: cwd, Focused: focused})
964 }
965 sort.Slice(rows, func(i, j int) bool { return rows[i].Pane < rows[j].Pane })
966 if asJSON {
967 return marshalListing(rows)
968 }
969 var out strings.Builder
970 out.WriteString("ID\tPANE\tSIZE\tCWD\tFOCUSED\n")
971 for _, row := range rows {
972 fmt.Fprintf(&out, "%s\t%s\t%s\t%s\t%t\n", row.ID, row.Pane, row.Size, row.CWD, row.Focused)
973 }
974 return out.String(), nil
975}
976
977func marshalListing(value any) (string, error) {
978 encoded, err := json.Marshal(value)
979 if err != nil {
980 return "", err
981 }
982 return string(encoded) + "\n", nil
983}