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/domain/layout"
15	"github.com/bnema/vev/internal/ports"
16	"github.com/bnema/vev/internal/protocol"
17	"github.com/bnema/vev/internal/protocol/catalogue"
18	"github.com/bnema/vev/internal/usecase/command"
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}

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