feat(daemon): dispatch one-shot control commands
- commit
- b253fabcdf43609d831f49bc0b152d9b85c0f474
- author
- brice · · 2026-07-23 22:04 UTC
- parent
- c81e580
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 624 | 624 | switch first.Type { | |
| 625 | 625 | case ports.MsgList: | |
| 626 | 626 | d.handleList(tr) | |
| 627 | + | case ports.MsgCommand: | |
| 628 | + | d.handleCommand(tr, first) | |
| 627 | 629 | case ports.MsgKill: | |
| 628 | 630 | d.handleKill(tr, first) | |
| 629 | 631 | 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: | ||