v0.3.0 99dc266

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

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