detached b253fab

feat(daemon): dispatch one-shot control commands

commit
b253fabcdf43609d831f49bc0b152d9b85c0f474
author
brice · · 2026-07-23 22:04 UTC
parent
c81e580
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +804 -0
A internal/usecase/daemon/control.go +450 -0
@@ -0,0 +1,450 @@
1+package daemon
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "fmt"
7+ "sort"
8+ "strings"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/usecase/command"
13+ "github.com/bnema/vev/internal/usecase/layout"
14+)
15+
16+// handleCommand serves one one-shot control request. The leading version is
17+// checked before decoding the versioned body so newer layouts fail cleanly.
18+func (d *Daemon) handleCommand(tr ports.Transport, f ports.Frame) {
19+ defer func() { _ = tr.Close() }()
20+
21+ if version, ok := ports.PeekCommandVersion(f.Payload); !ok || version != ports.ProtocolVersion {
22+ _ = tr.Send(frameCommandResult(ports.CommandResult{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"}))
23+ return
24+ }
25+ request, err := ports.UnmarshalCommandRequest(f.Payload)
26+ if err != nil {
27+ _ = tr.Send(frameCommandResult(ports.CommandResult{Code: ports.ErrInternal, Text: "malformed command request"}))
28+ return
29+ }
30+ _ = tr.Send(frameCommandResult(d.dispatchCommand(request)))
31+}
32+
33+func frameCommandResult(result ports.CommandResult) ports.Frame {
34+ return ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(result)}
35+}
36+
37+func (d *Daemon) dispatchCommand(request ports.CommandRequest) ports.CommandResult {
38+ cmd, ok := command.BySlug(request.Slug)
39+ if !ok {
40+ return commandFailure(ports.ErrUnknownCommand, "unknown command: "+request.Slug)
41+ }
42+ if !cmd.Scriptable || cmd.Control == nil {
43+ return commandFailure(ports.ErrNotScriptable, request.Slug+" requires an attached client")
44+ }
45+ if cmd.Target == command.TargetNone {
46+ return d.runControl(cmd, controlExec{d: d}, request)
47+ }
48+
49+ sess, code, text := d.resolveTargetSession(request)
50+ if sess == nil {
51+ return commandFailure(code, text)
52+ }
53+ sess.dispatchMu.Lock()
54+ defer sess.dispatchMu.Unlock()
55+
56+ tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request.TargetTab, request.TargetPane)
57+ if code != 0 {
58+ return commandFailure(code, text)
59+ }
60+ return d.runControl(cmd, controlExec{d: d, sess: sess, tab: tb, pane: pane}, request)
61+}
62+
63+func (d *Daemon) runControl(cmd command.Command, exec controlExec, request ports.CommandRequest) ports.CommandResult {
64+ result, err := cmd.Control(exec, request.Args, command.ControlOptions{JSON: request.JSON})
65+ if err == nil {
66+ return ports.CommandResult{OK: true, Output: result.Output}
67+ }
68+ switch {
69+ case errors.Is(err, command.ErrInvalidArguments), errors.Is(err, errSessionNameRequired):
70+ return commandFailure(ports.ErrInvalidCommandArgs, "usage: "+cmd.Usage)
71+ case errors.Is(err, errSessionNameInUse):
72+ return commandFailure(ports.ErrNameTaken, err.Error())
73+ default:
74+ return commandFailure(ports.ErrInternal, err.Error())
75+ }
76+}
77+
78+func commandFailure(code uint16, text string) ports.CommandResult {
79+ return ports.CommandResult{Code: code, Text: text}
80+}
81+
82+// resolveTargetSession applies explicit-name, stable-ID, then unique-session
83+// resolution. A name paired with IDs is advisory only when no live session has
84+// that name (the VEV value can survive a rename).
85+func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, uint16, string) {
86+ d.mu.Lock()
87+ defer d.mu.Unlock()
88+
89+ named := d.findByNameLocked(request.TargetSession)
90+ if request.TargetSession != "" && request.TargetTab == "" && request.TargetPane == "" {
91+ if named == nil {
92+ return nil, ports.ErrNoSuchTarget, "no such session: " + request.TargetSession
93+ }
94+ return named, 0, ""
95+ }
96+ if request.TargetTab != "" || request.TargetPane != "" {
97+ if request.TargetTab == "" || request.TargetPane == "" {
98+ return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together"
99+ }
100+ for _, sess := range d.sessions {
101+ if sess.containsStableIDs(request.TargetTab, request.TargetPane) {
102+ if named != nil && named != sess {
103+ return nil, ports.ErrNoSuchTarget, "tab/pane IDs belong to another session"
104+ }
105+ return sess, 0, ""
106+ }
107+ }
108+ return nil, ports.ErrNoSuchTarget, "no live session contains the target tab/pane"
109+ }
110+ if len(d.sessions) == 0 {
111+ return nil, ports.ErrNoSuchTarget, "no live sessions"
112+ }
113+ if len(d.sessions) != 1 {
114+ return nil, ports.ErrAmbiguousTarget, "several sessions are live; use -s <session> or run from inside a pane"
115+ }
116+ for _, sess := range d.sessions {
117+ return sess, 0, ""
118+ }
119+ panic("unreachable")
120+}
121+
122+func (s *session) containsStableIDs(tabID, paneID string) bool {
123+ s.mu.Lock()
124+ tabs := append([]*tab(nil), s.tabs...)
125+ s.mu.Unlock()
126+ for _, tb := range tabs {
127+ tb.mu.Lock()
128+ pane := paneByStableIDLocked(tb, paneID)
129+ match := tb.stableID == tabID && pane != nil
130+ tb.mu.Unlock()
131+ if match {
132+ return true
133+ }
134+ }
135+ return false
136+}
137+
138+func resolveControlTarget(sess *session, kind command.TargetKind, tabID, paneID string) (*tab, *pane, uint16, string) {
139+ sess.mu.Lock()
140+ tabs := append([]*tab(nil), sess.tabs...)
141+ active := sess.active
142+ sess.mu.Unlock()
143+ if len(tabs) == 0 || active < 0 || active >= len(tabs) {
144+ return nil, nil, ports.ErrNoSuchTarget, "target session has no active tab"
145+ }
146+ tb := tabs[active]
147+ if tabID != "" {
148+ tb = nil
149+ for _, candidate := range tabs {
150+ candidate.mu.Lock()
151+ match := candidate.stableID == tabID
152+ candidate.mu.Unlock()
153+ if match {
154+ tb = candidate
155+ break
156+ }
157+ }
158+ if tb == nil {
159+ return nil, nil, ports.ErrNoSuchTarget, "no such target tab"
160+ }
161+ }
162+ if kind != command.TargetPane {
163+ return tb, nil, 0, ""
164+ }
165+ tb.mu.Lock()
166+ var target *pane
167+ if paneID != "" {
168+ target = paneByStableIDLocked(tb, paneID)
169+ } else {
170+ target = tb.focusedPane()
171+ }
172+ tb.mu.Unlock()
173+ if target == nil {
174+ return nil, nil, ports.ErrNoSuchTarget, "no such target pane"
175+ }
176+ return tb, target, 0, ""
177+}
178+
179+func paneByStableIDLocked(tb *tab, stableID string) *pane {
180+ for _, pane := range tb.panes {
181+ if pane.stableID == stableID {
182+ return pane
183+ }
184+ }
185+ return nil
186+}
187+
188+// controlExec implements command.ControlContext against resolved daemon-owned
189+// targets. It deliberately contains no attached-client state.
190+type controlExec struct {
191+ d *Daemon
192+ sess *session
193+ tab *tab
194+ pane *pane
195+}
196+
197+func (e controlExec) client() *attachedClient {
198+ e.sess.mu.Lock()
199+ defer e.sess.mu.Unlock()
200+ return e.sess.client
201+}
202+
203+// selectTarget translates an explicit stable target to the canonical active
204+// tab/focus state expected by the existing daemon action seam.
205+func (e controlExec) selectTarget() {
206+ if e.tab == nil {
207+ return
208+ }
209+ e.sess.mu.Lock()
210+ for i, tb := range e.sess.tabs {
211+ if tb == e.tab {
212+ e.sess.active = i
213+ break
214+ }
215+ }
216+ e.sess.mu.Unlock()
217+ if e.pane != nil {
218+ e.tab.mu.Lock()
219+ e.tab.tree.Focus = e.pane.id
220+ e.tab.mu.Unlock()
221+ }
222+}
223+
224+func (e controlExec) CreateTab() error { return e.d.createTab(e.sess, e.sess.fullViewportSize()) }
225+func (e controlExec) CreateSessionNamed(name string) error {
226+ if err := domain.ValidateSessionName(name); err != nil {
227+ return command.ErrInvalidArguments
228+ }
229+ e.sess.mu.Lock()
230+ cwd, term, env := e.sess.cwd, e.sess.terminal, copyEnvironment(e.sess.env)
231+ e.sess.mu.Unlock()
232+ size := e.sess.fullViewportSize()
233+ e.d.mu.Lock()
234+ defer e.d.mu.Unlock()
235+ if e.d.closing {
236+ return errors.New("daemon is shutting down")
237+ }
238+ if e.d.nameLiveOrStoppedLocked(name) {
239+ return errSessionNameInUse
240+ }
241+ _, err := e.d.createSessionLocked(name, false, cwd, size, term, env)
242+ return err
243+}
244+func (e controlExec) CloseTab() error {
245+ e.selectTarget()
246+ e.d.closeTab(e.sess, e.tab, true)
247+ return nil
248+}
249+func (e controlExec) ClosePane() error {
250+ e.selectTarget()
251+ return e.d.closeFocusedPane(e.sess, e.client())
252+}
253+func (e controlExec) SplitRight() error {
254+ e.selectTarget()
255+ return e.d.splitPane(e.sess, e.client(), layout.Right)
256+}
257+func (e controlExec) SplitLeft() error {
258+ e.selectTarget()
259+ return e.d.splitPane(e.sess, e.client(), layout.Left)
260+}
261+func (e controlExec) SplitUp() error {
262+ e.selectTarget()
263+ return e.d.splitPane(e.sess, e.client(), layout.Up)
264+}
265+func (e controlExec) SplitDown() error {
266+ e.selectTarget()
267+ return e.d.splitPane(e.sess, e.client(), layout.Down)
268+}
269+func (e controlExec) StackPane() error { e.selectTarget(); return e.d.stackPane(e.sess, e.client()) }
270+func (e controlExec) ToggleStack() error {
271+ e.selectTarget()
272+ return e.d.toggleStack(e.sess, e.client())
273+}
274+func (e controlExec) FocusPaneLeft() error {
275+ e.selectTarget()
276+ return e.d.focusDir(e.sess, e.client(), layout.Left)
277+}
278+func (e controlExec) FocusPaneRight() error {
279+ e.selectTarget()
280+ return e.d.focusDir(e.sess, e.client(), layout.Right)
281+}
282+func (e controlExec) FocusPaneUp() error {
283+ e.selectTarget()
284+ return e.d.focusDir(e.sess, e.client(), layout.Up)
285+}
286+func (e controlExec) FocusPaneDown() error {
287+ e.selectTarget()
288+ return e.d.focusDir(e.sess, e.client(), layout.Down)
289+}
290+func (e controlExec) NextTab() error { return e.switchRelative(1) }
291+func (e controlExec) PrevTab() error { return e.switchRelative(-1) }
292+func (e controlExec) switchRelative(delta int) error {
293+ if e.sess.switchRelative(delta) {
294+ e.d.activateTab(e.sess, e.sess.activeTab())
295+ }
296+ return nil
297+}
298+func (e controlExec) RenameSessionTo(name string) error { return e.d.renameSession(e.sess, name) }
299+func (e controlExec) RenameTabTo(name string) error {
300+ e.selectTarget()
301+ return e.d.renameTab(e.sess, e.tab, name)
302+}
303+func (e controlExec) Toast(severity, message string) error {
304+ var level domain.NoticeSeverity
305+ switch severity {
306+ case "info":
307+ level = domain.NoticeInfo
308+ case "warn":
309+ level = domain.NoticeWarn
310+ case "error":
311+ level = domain.NoticeError
312+ default:
313+ return command.ErrInvalidArguments
314+ }
315+ if strings.TrimSpace(message) == "" {
316+ return command.ErrInvalidArguments
317+ }
318+ e.d.notify(e.sess, level, domain.NoticeUser, message, nil)
319+ return nil
320+}
321+
322+func (e controlExec) ListSessions(asJSON bool) (string, error) {
323+ type row struct {
324+ Name string `json:"name"`
325+ Ephemeral bool `json:"ephemeral"`
326+ Tabs int `json:"tabs"`
327+ Attached bool `json:"attached"`
328+ Active bool `json:"active"`
329+ }
330+ e.d.mu.Lock()
331+ sessions := make([]*session, 0, len(e.d.sessions))
332+ for _, sess := range e.d.sessions {
333+ sessions = append(sessions, sess)
334+ }
335+ e.d.mu.Unlock()
336+ var active *session
337+ for _, sess := range sessions {
338+ if active == nil || sess.mruAt.Load() > active.mruAt.Load() {
339+ active = sess
340+ }
341+ }
342+ rows := make([]row, 0, len(sessions))
343+ for _, sess := range sessions {
344+ sess.mu.Lock()
345+ rows = append(rows, row{Name: sess.name, Ephemeral: sess.ephemeral, Tabs: len(sess.tabs), Attached: sess.client != nil, Active: sess == active})
346+ sess.mu.Unlock()
347+ }
348+ sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name })
349+ if asJSON {
350+ return marshalListing(rows)
351+ }
352+ var out strings.Builder
353+ out.WriteString("NAME\tSTATE\tTABS\tATTACHED\tACTIVE\n")
354+ for _, row := range rows {
355+ state := "named"
356+ if row.Ephemeral {
357+ state = "ephemeral"
358+ }
359+ fmt.Fprintf(&out, "%s\t%s\t%d\t%t\t%t\n", row.Name, state, row.Tabs, row.Attached, row.Active)
360+ }
361+ return out.String(), nil
362+}
363+
364+func (e controlExec) ListTabs(asJSON bool) (string, error) {
365+ type row struct {
366+ Index int `json:"index"`
367+ ID string `json:"id"`
368+ Name string `json:"name"`
369+ Panes int `json:"panes"`
370+ Active bool `json:"active"`
371+ }
372+ e.sess.mu.Lock()
373+ tabs := append([]*tab(nil), e.sess.tabs...)
374+ active := e.sess.active
375+ e.sess.mu.Unlock()
376+ rows := make([]row, 0, len(tabs))
377+ for i, tb := range tabs {
378+ tb.mu.Lock()
379+ rows = append(rows, row{Index: i, ID: tb.stableID, Name: tb.name, Panes: len(tb.panes), Active: i == active})
380+ tb.mu.Unlock()
381+ }
382+ if asJSON {
383+ return marshalListing(rows)
384+ }
385+ var out strings.Builder
386+ out.WriteString("INDEX\tID\tNAME\tPANES\tACTIVE\n")
387+ for _, row := range rows {
388+ fmt.Fprintf(&out, "%d\t%s\t%s\t%d\t%t\n", row.Index, row.ID, row.Name, row.Panes, row.Active)
389+ }
390+ return out.String(), nil
391+}
392+
393+func (e controlExec) ListPanes(asJSON bool) (string, error) {
394+ type row struct {
395+ ID string `json:"id"`
396+ Pane string `json:"pane"`
397+ Size string `json:"size"`
398+ CWD string `json:"cwd"`
399+ Focused bool `json:"focused"`
400+ }
401+ tb := e.tab
402+ if tb == nil {
403+ tb = e.sess.activeTab()
404+ }
405+ if tb == nil {
406+ return "", errors.New("target session has no active tab")
407+ }
408+ e.sess.mu.Lock()
409+ fallbackCWD := e.sess.cwd
410+ e.sess.mu.Unlock()
411+ tb.mu.Lock()
412+ focus := tb.tree.Focus
413+ panes := tb.panesSnapshot()
414+ tb.mu.Unlock()
415+ rows := make([]row, 0, len(panes))
416+ for _, pane := range panes {
417+ pane.mu.Lock()
418+ size := domain.Size{Cols: pane.rect.Width, Rows: pane.rect.Height}
419+ pid := 0
420+ if pane.pty != nil {
421+ pid = pane.pty.Pid()
422+ }
423+ pane.mu.Unlock()
424+ cwd := fallbackCWD
425+ if e.d.procCwd != nil && pid > 0 {
426+ if live, err := e.d.procCwd(pid); err == nil && live != "" {
427+ cwd = live
428+ }
429+ }
430+ rows = append(rows, row{ID: pane.stableID, Pane: string(pane.id), Size: fmt.Sprintf("%dx%d", size.Cols, size.Rows), CWD: cwd, Focused: pane.id == focus})
431+ }
432+ sort.Slice(rows, func(i, j int) bool { return rows[i].Pane < rows[j].Pane })
433+ if asJSON {
434+ return marshalListing(rows)
435+ }
436+ var out strings.Builder
437+ out.WriteString("ID\tPANE\tSIZE\tCWD\tFOCUSED\n")
438+ for _, row := range rows {
439+ fmt.Fprintf(&out, "%s\t%s\t%s\t%s\t%t\n", row.ID, row.Pane, row.Size, row.CWD, row.Focused)
440+ }
441+ return out.String(), nil
442+}
443+
444+func marshalListing(value any) (string, error) {
445+ encoded, err := json.Marshal(value)
446+ if err != nil {
447+ return "", err
448+ }
449+ return string(encoded) + "\n", nil
450+}
@@ -0,0 +1,450 @@
1+package daemon
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "fmt"
7+ "sort"
8+ "strings"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/usecase/command"
13+ "github.com/bnema/vev/internal/usecase/layout"
14+)
15+
16+// handleCommand serves one one-shot control request. The leading version is
17+// checked before decoding the versioned body so newer layouts fail cleanly.
18+func (d *Daemon) handleCommand(tr ports.Transport, f ports.Frame) {
19+ defer func() { _ = tr.Close() }()
20+
21+ if version, ok := ports.PeekCommandVersion(f.Payload); !ok || version != ports.ProtocolVersion {
22+ _ = tr.Send(frameCommandResult(ports.CommandResult{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"}))
23+ return
24+ }
25+ request, err := ports.UnmarshalCommandRequest(f.Payload)
26+ if err != nil {
27+ _ = tr.Send(frameCommandResult(ports.CommandResult{Code: ports.ErrInternal, Text: "malformed command request"}))
28+ return
29+ }
30+ _ = tr.Send(frameCommandResult(d.dispatchCommand(request)))
31+}
32+
33+func frameCommandResult(result ports.CommandResult) ports.Frame {
34+ return ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(result)}
35+}
36+
37+func (d *Daemon) dispatchCommand(request ports.CommandRequest) ports.CommandResult {
38+ cmd, ok := command.BySlug(request.Slug)
39+ if !ok {
40+ return commandFailure(ports.ErrUnknownCommand, "unknown command: "+request.Slug)
41+ }
42+ if !cmd.Scriptable || cmd.Control == nil {
43+ return commandFailure(ports.ErrNotScriptable, request.Slug+" requires an attached client")
44+ }
45+ if cmd.Target == command.TargetNone {
46+ return d.runControl(cmd, controlExec{d: d}, request)
47+ }
48+
49+ sess, code, text := d.resolveTargetSession(request)
50+ if sess == nil {
51+ return commandFailure(code, text)
52+ }
53+ sess.dispatchMu.Lock()
54+ defer sess.dispatchMu.Unlock()
55+
56+ tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request.TargetTab, request.TargetPane)
57+ if code != 0 {
58+ return commandFailure(code, text)
59+ }
60+ return d.runControl(cmd, controlExec{d: d, sess: sess, tab: tb, pane: pane}, request)
61+}
62+
63+func (d *Daemon) runControl(cmd command.Command, exec controlExec, request ports.CommandRequest) ports.CommandResult {
64+ result, err := cmd.Control(exec, request.Args, command.ControlOptions{JSON: request.JSON})
65+ if err == nil {
66+ return ports.CommandResult{OK: true, Output: result.Output}
67+ }
68+ switch {
69+ case errors.Is(err, command.ErrInvalidArguments), errors.Is(err, errSessionNameRequired):
70+ return commandFailure(ports.ErrInvalidCommandArgs, "usage: "+cmd.Usage)
71+ case errors.Is(err, errSessionNameInUse):
72+ return commandFailure(ports.ErrNameTaken, err.Error())
73+ default:
74+ return commandFailure(ports.ErrInternal, err.Error())
75+ }
76+}
77+
78+func commandFailure(code uint16, text string) ports.CommandResult {
79+ return ports.CommandResult{Code: code, Text: text}
80+}
81+
82+// resolveTargetSession applies explicit-name, stable-ID, then unique-session
83+// resolution. A name paired with IDs is advisory only when no live session has
84+// that name (the VEV value can survive a rename).
85+func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, uint16, string) {
86+ d.mu.Lock()
87+ defer d.mu.Unlock()
88+
89+ named := d.findByNameLocked(request.TargetSession)
90+ if request.TargetSession != "" && request.TargetTab == "" && request.TargetPane == "" {
91+ if named == nil {
92+ return nil, ports.ErrNoSuchTarget, "no such session: " + request.TargetSession
93+ }
94+ return named, 0, ""
95+ }
96+ if request.TargetTab != "" || request.TargetPane != "" {
97+ if request.TargetTab == "" || request.TargetPane == "" {
98+ return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together"
99+ }
100+ for _, sess := range d.sessions {
101+ if sess.containsStableIDs(request.TargetTab, request.TargetPane) {
102+ if named != nil && named != sess {
103+ return nil, ports.ErrNoSuchTarget, "tab/pane IDs belong to another session"
104+ }
105+ return sess, 0, ""
106+ }
107+ }
108+ return nil, ports.ErrNoSuchTarget, "no live session contains the target tab/pane"
109+ }
110+ if len(d.sessions) == 0 {
111+ return nil, ports.ErrNoSuchTarget, "no live sessions"
112+ }
113+ if len(d.sessions) != 1 {
114+ return nil, ports.ErrAmbiguousTarget, "several sessions are live; use -s <session> or run from inside a pane"
115+ }
116+ for _, sess := range d.sessions {
117+ return sess, 0, ""
118+ }
119+ panic("unreachable")
120+}
121+
122+func (s *session) containsStableIDs(tabID, paneID string) bool {
123+ s.mu.Lock()
124+ tabs := append([]*tab(nil), s.tabs...)
125+ s.mu.Unlock()
126+ for _, tb := range tabs {
127+ tb.mu.Lock()
128+ pane := paneByStableIDLocked(tb, paneID)
129+ match := tb.stableID == tabID && pane != nil
130+ tb.mu.Unlock()
131+ if match {
132+ return true
133+ }
134+ }
135+ return false
136+}
137+
138+func resolveControlTarget(sess *session, kind command.TargetKind, tabID, paneID string) (*tab, *pane, uint16, string) {
139+ sess.mu.Lock()
140+ tabs := append([]*tab(nil), sess.tabs...)
141+ active := sess.active
142+ sess.mu.Unlock()
143+ if len(tabs) == 0 || active < 0 || active >= len(tabs) {
144+ return nil, nil, ports.ErrNoSuchTarget, "target session has no active tab"
145+ }
146+ tb := tabs[active]
147+ if tabID != "" {
148+ tb = nil
149+ for _, candidate := range tabs {
150+ candidate.mu.Lock()
151+ match := candidate.stableID == tabID
152+ candidate.mu.Unlock()
153+ if match {
154+ tb = candidate
155+ break
156+ }
157+ }
158+ if tb == nil {
159+ return nil, nil, ports.ErrNoSuchTarget, "no such target tab"
160+ }
161+ }
162+ if kind != command.TargetPane {
163+ return tb, nil, 0, ""
164+ }
165+ tb.mu.Lock()
166+ var target *pane
167+ if paneID != "" {
168+ target = paneByStableIDLocked(tb, paneID)
169+ } else {
170+ target = tb.focusedPane()
171+ }
172+ tb.mu.Unlock()
173+ if target == nil {
174+ return nil, nil, ports.ErrNoSuchTarget, "no such target pane"
175+ }
176+ return tb, target, 0, ""
177+}
178+
179+func paneByStableIDLocked(tb *tab, stableID string) *pane {
180+ for _, pane := range tb.panes {
181+ if pane.stableID == stableID {
182+ return pane
183+ }
184+ }
185+ return nil
186+}
187+
188+// controlExec implements command.ControlContext against resolved daemon-owned
189+// targets. It deliberately contains no attached-client state.
190+type controlExec struct {
191+ d *Daemon
192+ sess *session
193+ tab *tab
194+ pane *pane
195+}
196+
197+func (e controlExec) client() *attachedClient {
198+ e.sess.mu.Lock()
199+ defer e.sess.mu.Unlock()
200+ return e.sess.client
201+}
202+
203+// selectTarget translates an explicit stable target to the canonical active
204+// tab/focus state expected by the existing daemon action seam.
205+func (e controlExec) selectTarget() {
206+ if e.tab == nil {
207+ return
208+ }
209+ e.sess.mu.Lock()
210+ for i, tb := range e.sess.tabs {
211+ if tb == e.tab {
212+ e.sess.active = i
213+ break
214+ }
215+ }
216+ e.sess.mu.Unlock()
217+ if e.pane != nil {
218+ e.tab.mu.Lock()
219+ e.tab.tree.Focus = e.pane.id
220+ e.tab.mu.Unlock()
221+ }
222+}
223+
224+func (e controlExec) CreateTab() error { return e.d.createTab(e.sess, e.sess.fullViewportSize()) }
225+func (e controlExec) CreateSessionNamed(name string) error {
226+ if err := domain.ValidateSessionName(name); err != nil {
227+ return command.ErrInvalidArguments
228+ }
229+ e.sess.mu.Lock()
230+ cwd, term, env := e.sess.cwd, e.sess.terminal, copyEnvironment(e.sess.env)
231+ e.sess.mu.Unlock()
232+ size := e.sess.fullViewportSize()
233+ e.d.mu.Lock()
234+ defer e.d.mu.Unlock()
235+ if e.d.closing {
236+ return errors.New("daemon is shutting down")
237+ }
238+ if e.d.nameLiveOrStoppedLocked(name) {
239+ return errSessionNameInUse
240+ }
241+ _, err := e.d.createSessionLocked(name, false, cwd, size, term, env)
242+ return err
243+}
244+func (e controlExec) CloseTab() error {
245+ e.selectTarget()
246+ e.d.closeTab(e.sess, e.tab, true)
247+ return nil
248+}
249+func (e controlExec) ClosePane() error {
250+ e.selectTarget()
251+ return e.d.closeFocusedPane(e.sess, e.client())
252+}
253+func (e controlExec) SplitRight() error {
254+ e.selectTarget()
255+ return e.d.splitPane(e.sess, e.client(), layout.Right)
256+}
257+func (e controlExec) SplitLeft() error {
258+ e.selectTarget()
259+ return e.d.splitPane(e.sess, e.client(), layout.Left)
260+}
261+func (e controlExec) SplitUp() error {
262+ e.selectTarget()
263+ return e.d.splitPane(e.sess, e.client(), layout.Up)
264+}
265+func (e controlExec) SplitDown() error {
266+ e.selectTarget()
267+ return e.d.splitPane(e.sess, e.client(), layout.Down)
268+}
269+func (e controlExec) StackPane() error { e.selectTarget(); return e.d.stackPane(e.sess, e.client()) }
270+func (e controlExec) ToggleStack() error {
271+ e.selectTarget()
272+ return e.d.toggleStack(e.sess, e.client())
273+}
274+func (e controlExec) FocusPaneLeft() error {
275+ e.selectTarget()
276+ return e.d.focusDir(e.sess, e.client(), layout.Left)
277+}
278+func (e controlExec) FocusPaneRight() error {
279+ e.selectTarget()
280+ return e.d.focusDir(e.sess, e.client(), layout.Right)
281+}
282+func (e controlExec) FocusPaneUp() error {
283+ e.selectTarget()
284+ return e.d.focusDir(e.sess, e.client(), layout.Up)
285+}
286+func (e controlExec) FocusPaneDown() error {
287+ e.selectTarget()
288+ return e.d.focusDir(e.sess, e.client(), layout.Down)
289+}
290+func (e controlExec) NextTab() error { return e.switchRelative(1) }
291+func (e controlExec) PrevTab() error { return e.switchRelative(-1) }
292+func (e controlExec) switchRelative(delta int) error {
293+ if e.sess.switchRelative(delta) {
294+ e.d.activateTab(e.sess, e.sess.activeTab())
295+ }
296+ return nil
297+}
298+func (e controlExec) RenameSessionTo(name string) error { return e.d.renameSession(e.sess, name) }
299+func (e controlExec) RenameTabTo(name string) error {
300+ e.selectTarget()
301+ return e.d.renameTab(e.sess, e.tab, name)
302+}
303+func (e controlExec) Toast(severity, message string) error {
304+ var level domain.NoticeSeverity
305+ switch severity {
306+ case "info":
307+ level = domain.NoticeInfo
308+ case "warn":
309+ level = domain.NoticeWarn
310+ case "error":
311+ level = domain.NoticeError
312+ default:
313+ return command.ErrInvalidArguments
314+ }
315+ if strings.TrimSpace(message) == "" {
316+ return command.ErrInvalidArguments
317+ }
318+ e.d.notify(e.sess, level, domain.NoticeUser, message, nil)
319+ return nil
320+}
321+
322+func (e controlExec) ListSessions(asJSON bool) (string, error) {
323+ type row struct {
324+ Name string `json:"name"`
325+ Ephemeral bool `json:"ephemeral"`
326+ Tabs int `json:"tabs"`
327+ Attached bool `json:"attached"`
328+ Active bool `json:"active"`
329+ }
330+ e.d.mu.Lock()
331+ sessions := make([]*session, 0, len(e.d.sessions))
332+ for _, sess := range e.d.sessions {
333+ sessions = append(sessions, sess)
334+ }
335+ e.d.mu.Unlock()
336+ var active *session
337+ for _, sess := range sessions {
338+ if active == nil || sess.mruAt.Load() > active.mruAt.Load() {
339+ active = sess
340+ }
341+ }
342+ rows := make([]row, 0, len(sessions))
343+ for _, sess := range sessions {
344+ sess.mu.Lock()
345+ rows = append(rows, row{Name: sess.name, Ephemeral: sess.ephemeral, Tabs: len(sess.tabs), Attached: sess.client != nil, Active: sess == active})
346+ sess.mu.Unlock()
347+ }
348+ sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name })
349+ if asJSON {
350+ return marshalListing(rows)
351+ }
352+ var out strings.Builder
353+ out.WriteString("NAME\tSTATE\tTABS\tATTACHED\tACTIVE\n")
354+ for _, row := range rows {
355+ state := "named"
356+ if row.Ephemeral {
357+ state = "ephemeral"
358+ }
359+ fmt.Fprintf(&out, "%s\t%s\t%d\t%t\t%t\n", row.Name, state, row.Tabs, row.Attached, row.Active)
360+ }
361+ return out.String(), nil
362+}
363+
364+func (e controlExec) ListTabs(asJSON bool) (string, error) {
365+ type row struct {
366+ Index int `json:"index"`
367+ ID string `json:"id"`
368+ Name string `json:"name"`
369+ Panes int `json:"panes"`
370+ Active bool `json:"active"`
371+ }
372+ e.sess.mu.Lock()
373+ tabs := append([]*tab(nil), e.sess.tabs...)
374+ active := e.sess.active
375+ e.sess.mu.Unlock()
376+ rows := make([]row, 0, len(tabs))
377+ for i, tb := range tabs {
378+ tb.mu.Lock()
379+ rows = append(rows, row{Index: i, ID: tb.stableID, Name: tb.name, Panes: len(tb.panes), Active: i == active})
380+ tb.mu.Unlock()
381+ }
382+ if asJSON {
383+ return marshalListing(rows)
384+ }
385+ var out strings.Builder
386+ out.WriteString("INDEX\tID\tNAME\tPANES\tACTIVE\n")
387+ for _, row := range rows {
388+ fmt.Fprintf(&out, "%d\t%s\t%s\t%d\t%t\n", row.Index, row.ID, row.Name, row.Panes, row.Active)
389+ }
390+ return out.String(), nil
391+}
392+
393+func (e controlExec) ListPanes(asJSON bool) (string, error) {
394+ type row struct {
395+ ID string `json:"id"`
396+ Pane string `json:"pane"`
397+ Size string `json:"size"`
398+ CWD string `json:"cwd"`
399+ Focused bool `json:"focused"`
400+ }
401+ tb := e.tab
402+ if tb == nil {
403+ tb = e.sess.activeTab()
404+ }
405+ if tb == nil {
406+ return "", errors.New("target session has no active tab")
407+ }
408+ e.sess.mu.Lock()
409+ fallbackCWD := e.sess.cwd
410+ e.sess.mu.Unlock()
411+ tb.mu.Lock()
412+ focus := tb.tree.Focus
413+ panes := tb.panesSnapshot()
414+ tb.mu.Unlock()
415+ rows := make([]row, 0, len(panes))
416+ for _, pane := range panes {
417+ pane.mu.Lock()
418+ size := domain.Size{Cols: pane.rect.Width, Rows: pane.rect.Height}
419+ pid := 0
420+ if pane.pty != nil {
421+ pid = pane.pty.Pid()
422+ }
423+ pane.mu.Unlock()
424+ cwd := fallbackCWD
425+ if e.d.procCwd != nil && pid > 0 {
426+ if live, err := e.d.procCwd(pid); err == nil && live != "" {
427+ cwd = live
428+ }
429+ }
430+ rows = append(rows, row{ID: pane.stableID, Pane: string(pane.id), Size: fmt.Sprintf("%dx%d", size.Cols, size.Rows), CWD: cwd, Focused: pane.id == focus})
431+ }
432+ sort.Slice(rows, func(i, j int) bool { return rows[i].Pane < rows[j].Pane })
433+ if asJSON {
434+ return marshalListing(rows)
435+ }
436+ var out strings.Builder
437+ out.WriteString("ID\tPANE\tSIZE\tCWD\tFOCUSED\n")
438+ for _, row := range rows {
439+ fmt.Fprintf(&out, "%s\t%s\t%s\t%s\t%t\n", row.ID, row.Pane, row.Size, row.CWD, row.Focused)
440+ }
441+ return out.String(), nil
442+}
443+
444+func marshalListing(value any) (string, error) {
445+ encoded, err := json.Marshal(value)
446+ if err != nil {
447+ return "", err
448+ }
449+ return string(encoded) + "\n", nil
450+}
A internal/usecase/daemon/control_test.go +352 -0
@@ -0,0 +1,352 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestHandleCommandRejectsVersionBeforeDecodeOrDispatch(t *testing.T) {
16+ // The version prefix is valid and mismatched, while the remainder is not a
17+ // decodable request. The daemon must still return ErrVersionMismatch.
18+ frame := ports.Frame{Type: ports.MsgCommand, Payload: []byte{0, byte(ports.ProtocolVersion + 1)}}
19+ tr, sends, _ := newConn(t, frame)
20+
21+ d := newTestDaemon(t, nil, stubClock{})
22+ d.handleCommand(tr, frame)
23+
24+ result := awaitCommandResult(t, sends)
25+ require.False(t, result.OK)
26+ require.Equal(t, ports.ErrVersionMismatch, result.Code)
27+}
28+
29+func TestHandleConnRoutesCommand(t *testing.T) {
30+ d := newTestDaemon(t, nil, stubClock{})
31+ request := ports.CommandRequest{Version: ports.ProtocolVersion, Slug: "list-sessions"}
32+ frame := ports.Frame{Type: ports.MsgCommand, Payload: ports.MarshalCommandRequest(request)}
33+ tr, sends, _ := newConn(t, frame)
34+
35+ d.handleConn(tr)
36+
37+ result := awaitCommandResult(t, sends)
38+ require.True(t, result.OK, result.Text)
39+}
40+
41+func TestHandleCommandDispatchAndTargetErrors(t *testing.T) {
42+ tests := []struct {
43+ name string
44+ arrange func(*Daemon)
45+ request ports.CommandRequest
46+ code uint16
47+ }{
48+ {name: "unknown slug", request: ports.CommandRequest{Slug: "no-such"}, code: ports.ErrUnknownCommand},
49+ {name: "non-scriptable command", request: ports.CommandRequest{Slug: "session-picker"}, code: ports.ErrNotScriptable},
50+ {name: "no sessions", request: ports.CommandRequest{Slug: "split-right"}, code: ports.ErrNoSuchTarget},
51+ {name: "ambiguous sessions", arrange: func(d *Daemon) {
52+ addControlSession(d, "one", "t_one", "p_one")
53+ addControlSession(d, "two", "t_two", "p_two")
54+ }, request: ports.CommandRequest{Slug: "split-right"}, code: ports.ErrAmbiguousTarget},
55+ {name: "missing explicit session", arrange: func(d *Daemon) {
56+ addControlSession(d, "work", "t_work", "p_work")
57+ }, request: ports.CommandRequest{Slug: "new-tab", TargetSession: "missing"}, code: ports.ErrNoSuchTarget},
58+ {name: "missing stable IDs", arrange: func(d *Daemon) {
59+ addControlSession(d, "work", "t_work", "p_work")
60+ }, request: ports.CommandRequest{Slug: "split-right", TargetTab: "t_nope", TargetPane: "p_nope"}, code: ports.ErrNoSuchTarget},
61+ {name: "cross-session IDs rejected", arrange: func(d *Daemon) {
62+ addControlSession(d, "one", "t_one", "p_one")
63+ addControlSession(d, "two", "t_two", "p_two")
64+ }, request: ports.CommandRequest{Slug: "split-right", TargetSession: "one", TargetTab: "t_two", TargetPane: "p_two"}, code: ports.ErrNoSuchTarget},
65+ }
66+
67+ for _, tt := range tests {
68+ t.Run(tt.name, func(t *testing.T) {
69+ d := newTestDaemon(t, nil, stubClock{})
70+ if tt.arrange != nil {
71+ tt.arrange(d)
72+ }
73+ result := sendCommand(t, d, tt.request)
74+ require.False(t, result.OK)
75+ require.Equal(t, tt.code, result.Code)
76+ })
77+ }
78+}
79+
80+func TestHandleCommandResolvesStaleSessionNameByStableIDs(t *testing.T) {
81+ d := newTestDaemon(t, nil, stubClock{})
82+ sess := addControlSession(d, "renamed", "t_work", "p_work")
83+
84+ result := sendCommand(t, d, ports.CommandRequest{
85+ Slug: "toast", Args: []string{"hello"},
86+ TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work",
87+ })
88+
89+ require.True(t, result.OK, result.Text)
90+ require.Equal(t, uint64(1), sess.mruAt.Load())
91+}
92+
93+func TestHandleCommandTargetsTabByStableIDs(t *testing.T) {
94+ d := newTestDaemon(t, nil, stubClock{})
95+ sess := addControlSession(d, "work", "t_first", "p_first")
96+ second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
97+ second.ctx, second.cancel = context.WithCancel(d.serveCtx)
98+ sess.mu.Lock()
99+ sess.tabs = append(sess.tabs, second)
100+ sess.active = 0
101+ sess.mu.Unlock()
102+
103+ result := sendCommand(t, d, ports.CommandRequest{
104+ Slug: "rename-tab", Args: []string{"targeted"}, TargetSession: "work",
105+ TargetTab: "t_second", TargetPane: "p_second",
106+ })
107+
108+ require.True(t, result.OK, result.Text)
109+ second.mu.Lock()
110+ require.Equal(t, "targeted", second.name)
111+ second.mu.Unlock()
112+ sess.mu.Lock()
113+ require.Equal(t, 1, sess.active)
114+ sess.mu.Unlock()
115+}
116+
117+func TestHandleCommandHeadlessMutations(t *testing.T) {
118+ tests := []struct {
119+ name string
120+ slug string
121+ args []string
122+ verify func(*testing.T, *Daemon, *session)
123+ }{
124+ {name: "split focused pane", slug: "split-right", verify: func(t *testing.T, _ *Daemon, sess *session) {
125+ tb := sess.activeTab()
126+ tb.mu.Lock()
127+ defer tb.mu.Unlock()
128+ require.Len(t, tb.panes, 2)
129+ }},
130+ {name: "create tab from retained viewport", slug: "new-tab", verify: func(t *testing.T, _ *Daemon, sess *session) {
131+ sess.mu.Lock()
132+ defer sess.mu.Unlock()
133+ require.Len(t, sess.tabs, 2)
134+ require.Equal(t, 1, sess.active)
135+ }},
136+ {name: "rename tab", slug: "rename-tab", args: []string{"logs"}, verify: func(t *testing.T, _ *Daemon, sess *session) {
137+ tb := sess.activeTab()
138+ tb.mu.Lock()
139+ defer tb.mu.Unlock()
140+ require.Equal(t, "logs", tb.name)
141+ }},
142+ }
143+ for _, tt := range tests {
144+ t.Run(tt.name, func(t *testing.T) {
145+ factory := &controlPTYFactory{}
146+ d := newTestDaemon(t, factory, stubClock{})
147+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
148+ sess := addControlSession(d, "work", "t_work", "p_work")
149+
150+ result := sendCommand(t, d, ports.CommandRequest{Slug: tt.slug, Args: tt.args, TargetSession: "work"})
151+
152+ require.True(t, result.OK, result.Text)
153+ tt.verify(t, d, sess)
154+ })
155+ }
156+}
157+
158+func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T) {
159+ factory := &controlPTYFactory{}
160+ d := newTestDaemon(t, factory, stubClock{})
161+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
162+ source := addControlSession(d, "work", "t_work", "p_work")
163+ source.mu.Lock()
164+ source.cwd = "/tmp/work"
165+ source.env = []string{"INHERITED=yes"}
166+ source.terminal = terminalEnv{TrueColor: true}
167+ source.tabs[0].size = domain.Size{Cols: 118, Rows: 38}
168+ source.mu.Unlock()
169+
170+ result := sendCommand(t, d, ports.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
171+
172+ require.True(t, result.OK, result.Text)
173+ d.mu.Lock()
174+ created := d.findByNameLocked("scripted")
175+ d.mu.Unlock()
176+ require.NotNil(t, created)
177+ created.mu.Lock()
178+ require.Nil(t, created.client)
179+ require.Equal(t, "/tmp/work", created.cwd)
180+ require.Equal(t, []string{"INHERITED=yes"}, created.env)
181+ require.True(t, created.terminal.TrueColor)
182+ tb := created.tabs[0]
183+ created.mu.Unlock()
184+ tb.mu.Lock()
185+ require.Equal(t, domain.Size{Cols: 118, Rows: 38}, tb.size)
186+ tb.mu.Unlock()
187+ require.Nil(t, source.client)
188+
189+ taken := sendCommand(t, d, ports.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
190+ require.False(t, taken.OK)
191+ require.Equal(t, ports.ErrNameTaken, taken.Code)
192+}
193+
194+func TestHandleCommandValidatesToastAndQueuesForDetachedSession(t *testing.T) {
195+ d := newTestDaemon(t, nil, stubClock{})
196+ sess := addControlSession(d, "work", "t_work", "p_work")
197+
198+ bad := sendCommand(t, d, ports.CommandRequest{Slug: "toast", Args: []string{"-l", "loud", "hello"}, TargetSession: "work"})
199+ require.False(t, bad.OK)
200+ require.Equal(t, ports.ErrInvalidCommandArgs, bad.Code)
201+
202+ good := sendCommand(t, d, ports.CommandRequest{Slug: "toast", Args: []string{"-l", "warn", "hello"}, TargetSession: "work"})
203+ require.True(t, good.OK, good.Text)
204+ d.notices.mu.Lock()
205+ require.Len(t, d.notices.pending, 1)
206+ require.Equal(t, domain.NoticeUser, d.notices.pending[0].Code)
207+ require.Equal(t, sess.id, d.notices.pending[0].SessionID)
208+ d.notices.mu.Unlock()
209+}
210+
211+func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) {
212+ d := newTestDaemon(t, nil, stubClock{})
213+ sess := addControlSession(d, "work", "t_work", "p_work")
214+ sess.tabs[0].name = "shell"
215+
216+ sessions := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
217+ require.True(t, sessions.OK, sessions.Text)
218+ require.Contains(t, sessions.Output, "NAME\tSTATE\tTABS\tATTACHED\tACTIVE")
219+ require.Contains(t, sessions.Output, "work\tephemeral\t1\tfalse\ttrue")
220+
221+ tabs := sendCommand(t, d, ports.CommandRequest{Slug: "list-tabs", TargetSession: "work"})
222+ require.True(t, tabs.OK, tabs.Text)
223+ require.Contains(t, tabs.Output, "0\tt_work\tshell\t1\ttrue")
224+
225+ panes := sendCommand(t, d, ports.CommandRequest{Slug: "list-panes", TargetSession: "work", JSON: true})
226+ require.True(t, panes.OK, panes.Text)
227+ var decoded []map[string]any
228+ require.NoError(t, json.Unmarshal([]byte(panes.Output), &decoded))
229+ require.Len(t, decoded, 1)
230+ require.Equal(t, "p_work", decoded[0]["id"])
231+ require.Equal(t, "/tmp/work", decoded[0]["cwd"])
232+ require.Equal(t, true, decoded[0]["focused"])
233+}
234+
235+func TestHandleCommandSerializesResolutionAndExecutionPerSession(t *testing.T) {
236+ factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})}
237+ d := newTestDaemon(t, factory, stubClock{})
238+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
239+ sess := addControlSession(d, "work", "t_work", "p_work")
240+ original := sess.tabs[0].tree.Focus
241+
242+ firstFrame := commandFrame(ports.CommandRequest{Slug: "split-right", TargetSession: "work"})
243+ firstTransport, firstSends, _ := newConn(t, firstFrame)
244+ firstDone := make(chan struct{})
245+ go func() {
246+ d.handleCommand(firstTransport, firstFrame)
247+ close(firstDone)
248+ }()
249+ select {
250+ case <-factory.entered:
251+ case <-time.After(2 * time.Second):
252+ t.Fatal("first command did not enter its blocked action")
253+ }
254+
255+ secondFrame := commandFrame(ports.CommandRequest{Slug: "focus-pane-left", TargetSession: "work"})
256+ secondTransport, secondSends, _ := newConn(t, secondFrame)
257+ secondDone := make(chan struct{})
258+ go func() {
259+ d.handleCommand(secondTransport, secondFrame)
260+ close(secondDone)
261+ }()
262+ select {
263+ case <-secondDone:
264+ t.Fatal("second command bypassed serialization")
265+ case <-time.After(100 * time.Millisecond):
266+ }
267+ close(factory.release)
268+ <-firstDone
269+ <-secondDone
270+ require.True(t, awaitCommandResult(t, firstSends).OK)
271+ require.True(t, awaitCommandResult(t, secondSends).OK)
272+
273+ tb := sess.activeTab()
274+ tb.mu.Lock()
275+ defer tb.mu.Unlock()
276+ require.Len(t, tb.panes, 2)
277+ require.Equal(t, original, tb.tree.Focus, "second command must resolve after the split changed focus")
278+}
279+
280+func commandFrame(request ports.CommandRequest) ports.Frame {
281+ if request.Version == 0 {
282+ request.Version = ports.ProtocolVersion
283+ }
284+ return ports.Frame{Type: ports.MsgCommand, Payload: ports.MarshalCommandRequest(request)}
285+}
286+
287+func sendCommand(t *testing.T, d *Daemon, request ports.CommandRequest) ports.CommandResult {
288+ t.Helper()
289+ frame := commandFrame(request)
290+ tr, sends, _ := newConn(t, frame)
291+ d.handleCommand(tr, frame)
292+ return awaitCommandResult(t, sends)
293+}
294+
295+func awaitCommandResult(t *testing.T, sends chan ports.Frame) ports.CommandResult {
296+ t.Helper()
297+ reply := awaitFrame(t, sends, ports.MsgCommandResult)
298+ result, err := ports.UnmarshalCommandResult(reply.Payload)
299+ require.NoError(t, err)
300+ return result
301+}
302+
303+type controlPTYFactory struct {
304+ mu sync.Mutex
305+ ptys []*quietPTY
306+ entered chan struct{}
307+ release chan struct{}
308+ once sync.Once
309+}
310+
311+func (f *controlPTYFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
312+ f.once.Do(func() {
313+ if f.entered != nil {
314+ close(f.entered)
315+ <-f.release
316+ }
317+ })
318+ pty := newQuietPTY()
319+ f.mu.Lock()
320+ f.ptys = append(f.ptys, pty)
321+ f.mu.Unlock()
322+ return pty, nil
323+}
324+
325+func (f *controlPTYFactory) close() {
326+ if f.release != nil {
327+ select {
328+ case <-f.release:
329+ default:
330+ close(f.release)
331+ }
332+ }
333+ f.mu.Lock()
334+ defer f.mu.Unlock()
335+ for _, pty := range f.ptys {
336+ _ = pty.Close()
337+ }
338+}
339+
340+func addControlSession(d *Daemon, name, tabID, paneID string) *session {
341+ tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
342+ tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
343+ sess := &session{
344+ id: domain.SessionID("sess-" + name), name: name, ephemeral: true, cwd: "/tmp/" + name,
345+ ctx: d.serveCtx, cancel: func() {}, tabs: []*tab{tb}, env: []string{"MARK=" + name},
346+ }
347+ sess.mruAt.Store(1)
348+ d.mu.Lock()
349+ d.sessions[sess.id] = sess
350+ d.mu.Unlock()
351+ return sess
352+}
@@ -0,0 +1,352 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestHandleCommandRejectsVersionBeforeDecodeOrDispatch(t *testing.T) {
16+ // The version prefix is valid and mismatched, while the remainder is not a
17+ // decodable request. The daemon must still return ErrVersionMismatch.
18+ frame := ports.Frame{Type: ports.MsgCommand, Payload: []byte{0, byte(ports.ProtocolVersion + 1)}}
19+ tr, sends, _ := newConn(t, frame)
20+
21+ d := newTestDaemon(t, nil, stubClock{})
22+ d.handleCommand(tr, frame)
23+
24+ result := awaitCommandResult(t, sends)
25+ require.False(t, result.OK)
26+ require.Equal(t, ports.ErrVersionMismatch, result.Code)
27+}
28+
29+func TestHandleConnRoutesCommand(t *testing.T) {
30+ d := newTestDaemon(t, nil, stubClock{})
31+ request := ports.CommandRequest{Version: ports.ProtocolVersion, Slug: "list-sessions"}
32+ frame := ports.Frame{Type: ports.MsgCommand, Payload: ports.MarshalCommandRequest(request)}
33+ tr, sends, _ := newConn(t, frame)
34+
35+ d.handleConn(tr)
36+
37+ result := awaitCommandResult(t, sends)
38+ require.True(t, result.OK, result.Text)
39+}
40+
41+func TestHandleCommandDispatchAndTargetErrors(t *testing.T) {
42+ tests := []struct {
43+ name string
44+ arrange func(*Daemon)
45+ request ports.CommandRequest
46+ code uint16
47+ }{
48+ {name: "unknown slug", request: ports.CommandRequest{Slug: "no-such"}, code: ports.ErrUnknownCommand},
49+ {name: "non-scriptable command", request: ports.CommandRequest{Slug: "session-picker"}, code: ports.ErrNotScriptable},
50+ {name: "no sessions", request: ports.CommandRequest{Slug: "split-right"}, code: ports.ErrNoSuchTarget},
51+ {name: "ambiguous sessions", arrange: func(d *Daemon) {
52+ addControlSession(d, "one", "t_one", "p_one")
53+ addControlSession(d, "two", "t_two", "p_two")
54+ }, request: ports.CommandRequest{Slug: "split-right"}, code: ports.ErrAmbiguousTarget},
55+ {name: "missing explicit session", arrange: func(d *Daemon) {
56+ addControlSession(d, "work", "t_work", "p_work")
57+ }, request: ports.CommandRequest{Slug: "new-tab", TargetSession: "missing"}, code: ports.ErrNoSuchTarget},
58+ {name: "missing stable IDs", arrange: func(d *Daemon) {
59+ addControlSession(d, "work", "t_work", "p_work")
60+ }, request: ports.CommandRequest{Slug: "split-right", TargetTab: "t_nope", TargetPane: "p_nope"}, code: ports.ErrNoSuchTarget},
61+ {name: "cross-session IDs rejected", arrange: func(d *Daemon) {
62+ addControlSession(d, "one", "t_one", "p_one")
63+ addControlSession(d, "two", "t_two", "p_two")
64+ }, request: ports.CommandRequest{Slug: "split-right", TargetSession: "one", TargetTab: "t_two", TargetPane: "p_two"}, code: ports.ErrNoSuchTarget},
65+ }
66+
67+ for _, tt := range tests {
68+ t.Run(tt.name, func(t *testing.T) {
69+ d := newTestDaemon(t, nil, stubClock{})
70+ if tt.arrange != nil {
71+ tt.arrange(d)
72+ }
73+ result := sendCommand(t, d, tt.request)
74+ require.False(t, result.OK)
75+ require.Equal(t, tt.code, result.Code)
76+ })
77+ }
78+}
79+
80+func TestHandleCommandResolvesStaleSessionNameByStableIDs(t *testing.T) {
81+ d := newTestDaemon(t, nil, stubClock{})
82+ sess := addControlSession(d, "renamed", "t_work", "p_work")
83+
84+ result := sendCommand(t, d, ports.CommandRequest{
85+ Slug: "toast", Args: []string{"hello"},
86+ TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work",
87+ })
88+
89+ require.True(t, result.OK, result.Text)
90+ require.Equal(t, uint64(1), sess.mruAt.Load())
91+}
92+
93+func TestHandleCommandTargetsTabByStableIDs(t *testing.T) {
94+ d := newTestDaemon(t, nil, stubClock{})
95+ sess := addControlSession(d, "work", "t_first", "p_first")
96+ second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
97+ second.ctx, second.cancel = context.WithCancel(d.serveCtx)
98+ sess.mu.Lock()
99+ sess.tabs = append(sess.tabs, second)
100+ sess.active = 0
101+ sess.mu.Unlock()
102+
103+ result := sendCommand(t, d, ports.CommandRequest{
104+ Slug: "rename-tab", Args: []string{"targeted"}, TargetSession: "work",
105+ TargetTab: "t_second", TargetPane: "p_second",
106+ })
107+
108+ require.True(t, result.OK, result.Text)
109+ second.mu.Lock()
110+ require.Equal(t, "targeted", second.name)
111+ second.mu.Unlock()
112+ sess.mu.Lock()
113+ require.Equal(t, 1, sess.active)
114+ sess.mu.Unlock()
115+}
116+
117+func TestHandleCommandHeadlessMutations(t *testing.T) {
118+ tests := []struct {
119+ name string
120+ slug string
121+ args []string
122+ verify func(*testing.T, *Daemon, *session)
123+ }{
124+ {name: "split focused pane", slug: "split-right", verify: func(t *testing.T, _ *Daemon, sess *session) {
125+ tb := sess.activeTab()
126+ tb.mu.Lock()
127+ defer tb.mu.Unlock()
128+ require.Len(t, tb.panes, 2)
129+ }},
130+ {name: "create tab from retained viewport", slug: "new-tab", verify: func(t *testing.T, _ *Daemon, sess *session) {
131+ sess.mu.Lock()
132+ defer sess.mu.Unlock()
133+ require.Len(t, sess.tabs, 2)
134+ require.Equal(t, 1, sess.active)
135+ }},
136+ {name: "rename tab", slug: "rename-tab", args: []string{"logs"}, verify: func(t *testing.T, _ *Daemon, sess *session) {
137+ tb := sess.activeTab()
138+ tb.mu.Lock()
139+ defer tb.mu.Unlock()
140+ require.Equal(t, "logs", tb.name)
141+ }},
142+ }
143+ for _, tt := range tests {
144+ t.Run(tt.name, func(t *testing.T) {
145+ factory := &controlPTYFactory{}
146+ d := newTestDaemon(t, factory, stubClock{})
147+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
148+ sess := addControlSession(d, "work", "t_work", "p_work")
149+
150+ result := sendCommand(t, d, ports.CommandRequest{Slug: tt.slug, Args: tt.args, TargetSession: "work"})
151+
152+ require.True(t, result.OK, result.Text)
153+ tt.verify(t, d, sess)
154+ })
155+ }
156+}
157+
158+func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T) {
159+ factory := &controlPTYFactory{}
160+ d := newTestDaemon(t, factory, stubClock{})
161+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
162+ source := addControlSession(d, "work", "t_work", "p_work")
163+ source.mu.Lock()
164+ source.cwd = "/tmp/work"
165+ source.env = []string{"INHERITED=yes"}
166+ source.terminal = terminalEnv{TrueColor: true}
167+ source.tabs[0].size = domain.Size{Cols: 118, Rows: 38}
168+ source.mu.Unlock()
169+
170+ result := sendCommand(t, d, ports.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
171+
172+ require.True(t, result.OK, result.Text)
173+ d.mu.Lock()
174+ created := d.findByNameLocked("scripted")
175+ d.mu.Unlock()
176+ require.NotNil(t, created)
177+ created.mu.Lock()
178+ require.Nil(t, created.client)
179+ require.Equal(t, "/tmp/work", created.cwd)
180+ require.Equal(t, []string{"INHERITED=yes"}, created.env)
181+ require.True(t, created.terminal.TrueColor)
182+ tb := created.tabs[0]
183+ created.mu.Unlock()
184+ tb.mu.Lock()
185+ require.Equal(t, domain.Size{Cols: 118, Rows: 38}, tb.size)
186+ tb.mu.Unlock()
187+ require.Nil(t, source.client)
188+
189+ taken := sendCommand(t, d, ports.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
190+ require.False(t, taken.OK)
191+ require.Equal(t, ports.ErrNameTaken, taken.Code)
192+}
193+
194+func TestHandleCommandValidatesToastAndQueuesForDetachedSession(t *testing.T) {
195+ d := newTestDaemon(t, nil, stubClock{})
196+ sess := addControlSession(d, "work", "t_work", "p_work")
197+
198+ bad := sendCommand(t, d, ports.CommandRequest{Slug: "toast", Args: []string{"-l", "loud", "hello"}, TargetSession: "work"})
199+ require.False(t, bad.OK)
200+ require.Equal(t, ports.ErrInvalidCommandArgs, bad.Code)
201+
202+ good := sendCommand(t, d, ports.CommandRequest{Slug: "toast", Args: []string{"-l", "warn", "hello"}, TargetSession: "work"})
203+ require.True(t, good.OK, good.Text)
204+ d.notices.mu.Lock()
205+ require.Len(t, d.notices.pending, 1)
206+ require.Equal(t, domain.NoticeUser, d.notices.pending[0].Code)
207+ require.Equal(t, sess.id, d.notices.pending[0].SessionID)
208+ d.notices.mu.Unlock()
209+}
210+
211+func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) {
212+ d := newTestDaemon(t, nil, stubClock{})
213+ sess := addControlSession(d, "work", "t_work", "p_work")
214+ sess.tabs[0].name = "shell"
215+
216+ sessions := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
217+ require.True(t, sessions.OK, sessions.Text)
218+ require.Contains(t, sessions.Output, "NAME\tSTATE\tTABS\tATTACHED\tACTIVE")
219+ require.Contains(t, sessions.Output, "work\tephemeral\t1\tfalse\ttrue")
220+
221+ tabs := sendCommand(t, d, ports.CommandRequest{Slug: "list-tabs", TargetSession: "work"})
222+ require.True(t, tabs.OK, tabs.Text)
223+ require.Contains(t, tabs.Output, "0\tt_work\tshell\t1\ttrue")
224+
225+ panes := sendCommand(t, d, ports.CommandRequest{Slug: "list-panes", TargetSession: "work", JSON: true})
226+ require.True(t, panes.OK, panes.Text)
227+ var decoded []map[string]any
228+ require.NoError(t, json.Unmarshal([]byte(panes.Output), &decoded))
229+ require.Len(t, decoded, 1)
230+ require.Equal(t, "p_work", decoded[0]["id"])
231+ require.Equal(t, "/tmp/work", decoded[0]["cwd"])
232+ require.Equal(t, true, decoded[0]["focused"])
233+}
234+
235+func TestHandleCommandSerializesResolutionAndExecutionPerSession(t *testing.T) {
236+ factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})}
237+ d := newTestDaemon(t, factory, stubClock{})
238+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
239+ sess := addControlSession(d, "work", "t_work", "p_work")
240+ original := sess.tabs[0].tree.Focus
241+
242+ firstFrame := commandFrame(ports.CommandRequest{Slug: "split-right", TargetSession: "work"})
243+ firstTransport, firstSends, _ := newConn(t, firstFrame)
244+ firstDone := make(chan struct{})
245+ go func() {
246+ d.handleCommand(firstTransport, firstFrame)
247+ close(firstDone)
248+ }()
249+ select {
250+ case <-factory.entered:
251+ case <-time.After(2 * time.Second):
252+ t.Fatal("first command did not enter its blocked action")
253+ }
254+
255+ secondFrame := commandFrame(ports.CommandRequest{Slug: "focus-pane-left", TargetSession: "work"})
256+ secondTransport, secondSends, _ := newConn(t, secondFrame)
257+ secondDone := make(chan struct{})
258+ go func() {
259+ d.handleCommand(secondTransport, secondFrame)
260+ close(secondDone)
261+ }()
262+ select {
263+ case <-secondDone:
264+ t.Fatal("second command bypassed serialization")
265+ case <-time.After(100 * time.Millisecond):
266+ }
267+ close(factory.release)
268+ <-firstDone
269+ <-secondDone
270+ require.True(t, awaitCommandResult(t, firstSends).OK)
271+ require.True(t, awaitCommandResult(t, secondSends).OK)
272+
273+ tb := sess.activeTab()
274+ tb.mu.Lock()
275+ defer tb.mu.Unlock()
276+ require.Len(t, tb.panes, 2)
277+ require.Equal(t, original, tb.tree.Focus, "second command must resolve after the split changed focus")
278+}
279+
280+func commandFrame(request ports.CommandRequest) ports.Frame {
281+ if request.Version == 0 {
282+ request.Version = ports.ProtocolVersion
283+ }
284+ return ports.Frame{Type: ports.MsgCommand, Payload: ports.MarshalCommandRequest(request)}
285+}
286+
287+func sendCommand(t *testing.T, d *Daemon, request ports.CommandRequest) ports.CommandResult {
288+ t.Helper()
289+ frame := commandFrame(request)
290+ tr, sends, _ := newConn(t, frame)
291+ d.handleCommand(tr, frame)
292+ return awaitCommandResult(t, sends)
293+}
294+
295+func awaitCommandResult(t *testing.T, sends chan ports.Frame) ports.CommandResult {
296+ t.Helper()
297+ reply := awaitFrame(t, sends, ports.MsgCommandResult)
298+ result, err := ports.UnmarshalCommandResult(reply.Payload)
299+ require.NoError(t, err)
300+ return result
301+}
302+
303+type controlPTYFactory struct {
304+ mu sync.Mutex
305+ ptys []*quietPTY
306+ entered chan struct{}
307+ release chan struct{}
308+ once sync.Once
309+}
310+
311+func (f *controlPTYFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
312+ f.once.Do(func() {
313+ if f.entered != nil {
314+ close(f.entered)
315+ <-f.release
316+ }
317+ })
318+ pty := newQuietPTY()
319+ f.mu.Lock()
320+ f.ptys = append(f.ptys, pty)
321+ f.mu.Unlock()
322+ return pty, nil
323+}
324+
325+func (f *controlPTYFactory) close() {
326+ if f.release != nil {
327+ select {
328+ case <-f.release:
329+ default:
330+ close(f.release)
331+ }
332+ }
333+ f.mu.Lock()
334+ defer f.mu.Unlock()
335+ for _, pty := range f.ptys {
336+ _ = pty.Close()
337+ }
338+}
339+
340+func addControlSession(d *Daemon, name, tabID, paneID string) *session {
341+ tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
342+ tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
343+ sess := &session{
344+ id: domain.SessionID("sess-" + name), name: name, ephemeral: true, cwd: "/tmp/" + name,
345+ ctx: d.serveCtx, cancel: func() {}, tabs: []*tab{tb}, env: []string{"MARK=" + name},
346+ }
347+ sess.mruAt.Store(1)
348+ d.mu.Lock()
349+ d.sessions[sess.id] = sess
350+ d.mu.Unlock()
351+ return sess
352+}
M internal/usecase/daemon/daemon.go +2 -0
@@ -624,6 +624,8 @@ func (d *Daemon) handleConn(tr ports.Transport) {
624624 switch first.Type {
625625 case ports.MsgList:
626626 d.handleList(tr)
627+ case ports.MsgCommand:
628+ d.handleCommand(tr, first)
627629 case ports.MsgKill:
628630 d.handleKill(tr, first)
629631 case ports.MsgHello:
@@ -624,6 +624,8 @@ func (d *Daemon) handleConn(tr ports.Transport) {
624 switch first.Type {624 switch first.Type {
625 case ports.MsgList:625 case ports.MsgList:
626 d.handleList(tr)626 d.handleList(tr)
627+ case ports.MsgCommand:
628+ d.handleCommand(tr, first)
627 case ports.MsgKill:629 case ports.MsgKill:
628 d.handleKill(tr, first)630 d.handleKill(tr, first)
629 case ports.MsgHello:631 case ports.MsgHello:

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