control.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close