Merge pull request #124 from bnema/feat/control-api
feat: add control command API
38 files · +2947 -233
M AGENTS.md +2 -2
| @@ -64,9 +64,9 @@ Before touching daemon teardown paths, read the lock-ordering notes at the top o | |||
| 64 | 64 | Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/wire.go`. Connection framing lives in `internal/adapters/ipc/transport.go`. | |
| 65 | 65 | ||
| 66 | 66 | - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload. | |
| 67 | - | - Client message types are `1–15`; server message types are `16+`. | |
| 67 | + | - Client message types currently occupy `1–12` (`MsgCommand` is `12`); server types currently occupy `16–22` (`MsgCommandResult` is `22`). The reserved bands are `1–15` and `16+` respectively. | |
| 68 | 68 | - Version negotiation requires strict equality. | |
| 69 | - | - `Hello.Version` must stay first so `PeekHelloVersion` works. | |
| 69 | + | - `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work. | |
| 70 | 70 | - Bump `ProtocolVersion` for any message layout change. | |
| 71 | 71 | ||
| 72 | 72 | ## Session flow | |
| @@ -64,9 +64,9 @@ Before touching daemon teardown paths, read the lock-ordering notes at the top o | |||||
| 64 | Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/wire.go`. Connection framing lives in `internal/adapters/ipc/transport.go`. | 64 | Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/wire.go`. Connection framing lives in `internal/adapters/ipc/transport.go`. | ||
| 65 | 65 | ||||
| 66 | - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload. | 66 | - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload. | ||
| 67 | - | - Client message types are `1–15`; server message types are `16+`. | 67 | + | - Client message types currently occupy `1–12` (`MsgCommand` is `12`); server types currently occupy `16–22` (`MsgCommandResult` is `22`). The reserved bands are `1–15` and `16+` respectively. |
| 68 | - Version negotiation requires strict equality. | 68 | - Version negotiation requires strict equality. | ||
| 69 | - | - `Hello.Version` must stay first so `PeekHelloVersion` works. | 69 | + | - `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work. |
| 70 | - Bump `ProtocolVersion` for any message layout change. | 70 | - Bump `ProtocolVersion` for any message layout change. | ||
| 71 | 71 | ||||
| 72 | ## Session flow | 72 | ## Session flow | ||
M README.md +12 -0
| @@ -40,6 +40,18 @@ vev kill <name> kill a session (--all kills everything) | |||
| 40 | 40 | ||
| 41 | 41 | The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, scrollback, and allowlisted processes. | |
| 42 | 42 | ||
| 43 | + | ## Scripting | |
| 44 | + | ||
| 45 | + | `vev cmd` runs control commands against a running daemon; it never starts one. For example: | |
| 46 | + | ||
| 47 | + | ```sh | |
| 48 | + | vev cmd split-right | |
| 49 | + | vev cmd toast -l warn "build failed" | |
| 50 | + | vev cmd list-panes --json | |
| 51 | + | ``` | |
| 52 | + | ||
| 53 | + | Target a session explicitly with `-s` (`vev cmd -s work new-tab`). Inside a pane, `--self` targets that pane; it cannot be combined with `-s`. Otherwise vev uses `$VEV` inside a pane, then the only live session; ambiguous targets fail. Run `vev cmd --help` for the scriptable command list and `vev cmd <command> --help` for command usage. | |
| 54 | + | ||
| 43 | 55 | ## Keys | |
| 44 | 56 | ||
| 45 | 57 | No prefix key; everything is Alt. | |
| @@ -40,6 +40,18 @@ vev kill <name> kill a session (--all kills everything) | |||||
| 40 | 40 | ||||
| 41 | The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, scrollback, and allowlisted processes. | 41 | The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, scrollback, and allowlisted processes. | ||
| 42 | 42 | ||||
| 43 | + | ## Scripting | |||
| 44 | + | ||||
| 45 | + | `vev cmd` runs control commands against a running daemon; it never starts one. For example: | |||
| 46 | + | ||||
| 47 | + | ```sh | |||
| 48 | + | vev cmd split-right | |||
| 49 | + | vev cmd toast -l warn "build failed" | |||
| 50 | + | vev cmd list-panes --json | |||
| 51 | + | ``` | |||
| 52 | + | ||||
| 53 | + | Target a session explicitly with `-s` (`vev cmd -s work new-tab`). Inside a pane, `--self` targets that pane; it cannot be combined with `-s`. Otherwise vev uses `$VEV` inside a pane, then the only live session; ambiguous targets fail. Run `vev cmd --help` for the scriptable command list and `vev cmd <command> --help` for command usage. | |||
| 54 | + | ||||
| 43 | ## Keys | 55 | ## Keys | ||
| 44 | 56 | ||||
| 45 | No prefix key; everything is Alt. | 57 | No prefix key; everything is Alt. | ||
M internal/adapters/shellcmd/runner.go +2 -2
| @@ -18,7 +18,7 @@ type Runner struct{} | |||
| 18 | 18 | func New() Runner { return Runner{} } | |
| 19 | 19 | ||
| 20 | 20 | // Run executes spec.Command through sh -c, capturing bounded stdout and stderr. | |
| 21 | - | func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error) { | |
| 21 | + | func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.ShellCommandResult, error) { | |
| 22 | 22 | cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command) | |
| 23 | 23 | cmd.Env = spec.Env | |
| 24 | 24 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |
| @@ -36,7 +36,7 @@ func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandRes | |||
| 36 | 36 | cmd.Stderr = &stderr | |
| 37 | 37 | ||
| 38 | 38 | err := cmd.Run() | |
| 39 | - | res := ports.CommandResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()} | |
| 39 | + | res := ports.ShellCommandResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()} | |
| 40 | 40 | if err != nil { | |
| 41 | 41 | var exitErr *exec.ExitError | |
| 42 | 42 | if errors.As(err, &exitErr) { | |
| @@ -18,7 +18,7 @@ type Runner struct{} | |||||
| 18 | func New() Runner { return Runner{} } | 18 | func New() Runner { return Runner{} } | ||
| 19 | 19 | ||||
| 20 | // Run executes spec.Command through sh -c, capturing bounded stdout and stderr. | 20 | // Run executes spec.Command through sh -c, capturing bounded stdout and stderr. | ||
| 21 | - | func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error) { | 21 | + | func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.ShellCommandResult, error) { |
| 22 | cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command) | 22 | cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command) | ||
| 23 | cmd.Env = spec.Env | 23 | cmd.Env = spec.Env | ||
| 24 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | 24 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | ||
| @@ -36,7 +36,7 @@ func (Runner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandRes | |||||
| 36 | cmd.Stderr = &stderr | 36 | cmd.Stderr = &stderr | ||
| 37 | 37 | ||||
| 38 | err := cmd.Run() | 38 | err := cmd.Run() | ||
| 39 | - | res := ports.CommandResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()} | 39 | + | res := ports.ShellCommandResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()} |
| 40 | if err != nil { | 40 | if err != nil { | ||
| 41 | var exitErr *exec.ExitError | 41 | var exitErr *exec.ExitError | ||
| 42 | if errors.As(err, &exitErr) { | 42 | if errors.As(err, &exitErr) { | ||
A internal/app/cmd.go +205 -0
| @@ -0,0 +1,205 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "os" | |
| 9 | + | "sort" | |
| 10 | + | "strings" | |
| 11 | + | "text/tabwriter" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/adapters/ipc" | |
| 14 | + | "github.com/bnema/vev/internal/ports" | |
| 15 | + | commandusecase "github.com/bnema/vev/internal/usecase/command" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | type cmdInvocation struct { | |
| 19 | + | slug string | |
| 20 | + | args []string | |
| 21 | + | session string | |
| 22 | + | self bool | |
| 23 | + | jsonOut bool | |
| 24 | + | help bool | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | type exitCoded struct { | |
| 28 | + | code int | |
| 29 | + | err error | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func (e *exitCoded) Error() string { return e.err.Error() } | |
| 33 | + | func (e *exitCoded) Unwrap() error { return e.err } | |
| 34 | + | ||
| 35 | + | func exitCode(err error) int { | |
| 36 | + | if err == nil { | |
| 37 | + | return 0 | |
| 38 | + | } | |
| 39 | + | if coded, ok := errors.AsType[*exitCoded](err); ok { | |
| 40 | + | return coded.code | |
| 41 | + | } | |
| 42 | + | if _, ok := errors.AsType[*usageError](err); ok { | |
| 43 | + | return 2 | |
| 44 | + | } | |
| 45 | + | return 1 | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // ExitCode maps application outcomes to the CLI contract. | |
| 49 | + | func ExitCode(err error) int { return exitCode(err) } | |
| 50 | + | ||
| 51 | + | func parseCmdArgs(args []string) (cmdInvocation, error) { | |
| 52 | + | invocation := cmdInvocation{} | |
| 53 | + | index := 0 | |
| 54 | + | for index < len(args) && invocation.slug == "" { | |
| 55 | + | switch args[index] { | |
| 56 | + | case "--help", "-h": | |
| 57 | + | invocation.help = true | |
| 58 | + | index++ | |
| 59 | + | case "-s": | |
| 60 | + | if index+1 >= len(args) || args[index+1] == "" { | |
| 61 | + | return cmdInvocation{}, usagef("`cmd -s` requires a session name") | |
| 62 | + | } | |
| 63 | + | invocation.session = args[index+1] | |
| 64 | + | index += 2 | |
| 65 | + | case "--self": | |
| 66 | + | invocation.self = true | |
| 67 | + | index++ | |
| 68 | + | case "--json": | |
| 69 | + | invocation.jsonOut = true | |
| 70 | + | index++ | |
| 71 | + | default: | |
| 72 | + | if strings.HasPrefix(args[index], "-") { | |
| 73 | + | return cmdInvocation{}, usagef("unknown flag %q for `cmd`", args[index]) | |
| 74 | + | } | |
| 75 | + | invocation.slug = args[index] | |
| 76 | + | index++ | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | if invocation.slug == "" { | |
| 80 | + | invocation.help = true | |
| 81 | + | return invocation, nil | |
| 82 | + | } | |
| 83 | + | if invocation.self && invocation.session != "" { | |
| 84 | + | return cmdInvocation{}, usagef("`cmd --self` cannot be combined with `-s`") | |
| 85 | + | } | |
| 86 | + | cmd, ok := commandusecase.BySlug(invocation.slug) | |
| 87 | + | if !ok || !cmd.Scriptable { | |
| 88 | + | return cmdInvocation{}, usagef("unknown command %q; see `vev cmd --help`", invocation.slug) | |
| 89 | + | } | |
| 90 | + | for _, arg := range args[index:] { | |
| 91 | + | switch arg { | |
| 92 | + | case "--help", "-h": | |
| 93 | + | invocation.help = true | |
| 94 | + | return invocation, nil | |
| 95 | + | case "--json": | |
| 96 | + | invocation.jsonOut = true | |
| 97 | + | default: | |
| 98 | + | invocation.args = append(invocation.args, arg) | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | return invocation, nil | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func cmdHelp(invocation cmdInvocation) string { | |
| 105 | + | if invocation.slug != "" { | |
| 106 | + | cmd, _ := commandusecase.BySlug(invocation.slug) | |
| 107 | + | return fmt.Sprintf("usage: vev cmd [-s <session>] [--self] %s\n\n%s", cmd.Usage, cmd.Desc) | |
| 108 | + | } | |
| 109 | + | var out strings.Builder | |
| 110 | + | out.WriteString("usage: vev cmd [-s <session>] [--self] <command> [args]\n\ncommands:\n") | |
| 111 | + | writer := tabwriter.NewWriter(&out, 0, 4, 2, ' ', 0) | |
| 112 | + | commands := commandusecase.Registry() | |
| 113 | + | sort.Slice(commands, func(i, j int) bool { return commands[i].Slug < commands[j].Slug }) | |
| 114 | + | for _, cmd := range commands { | |
| 115 | + | if cmd.Scriptable { | |
| 116 | + | _, _ = fmt.Fprintf(writer, " %s\t%s\n", cmd.Usage, cmd.Desc) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | _ = writer.Flush() | |
| 120 | + | return out.String() | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | var errDaemonUnreachable = errors.New("daemon not running") | |
| 124 | + | ||
| 125 | + | type cmdDeps struct { | |
| 126 | + | stdout io.Writer | |
| 127 | + | getenv func(string) string | |
| 128 | + | dial func(context.Context, string) (ports.Transport, error) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func runCmd(ctx context.Context, invocation cmdInvocation) error { | |
| 132 | + | return runCmdWithDeps(ctx, invocation, cmdDeps{stdout: os.Stdout, getenv: os.Getenv, dial: realDial}) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) error { | |
| 136 | + | if invocation.help { | |
| 137 | + | _, err := fmt.Fprintln(deps.stdout, cmdHelp(invocation)) | |
| 138 | + | return err | |
| 139 | + | } | |
| 140 | + | request := ports.CommandRequest{ | |
| 141 | + | Version: ports.ProtocolVersion, | |
| 142 | + | Self: invocation.self, | |
| 143 | + | Slug: invocation.slug, | |
| 144 | + | Args: invocation.args, | |
| 145 | + | TargetSession: invocation.session, | |
| 146 | + | JSON: invocation.jsonOut, | |
| 147 | + | } | |
| 148 | + | if identity, ok := parseVEVEnv(deps.getenv("VEV")); ok { | |
| 149 | + | if request.TargetSession == "" { | |
| 150 | + | request.TargetSession = identity.session | |
| 151 | + | } | |
| 152 | + | if invocation.self || invocation.session == "" { | |
| 153 | + | request.TargetTab = identity.tab | |
| 154 | + | request.TargetPane = identity.pane | |
| 155 | + | } | |
| 156 | + | } else if invocation.self { | |
| 157 | + | return usagef("--self requires running inside a vev pane") | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | payload, err := ports.MarshalCommandRequest(request) | |
| 161 | + | if errors.Is(err, ports.ErrTooManyCommandArgs) { | |
| 162 | + | return usagef("too many command arguments") | |
| 163 | + | } | |
| 164 | + | if err != nil { | |
| 165 | + | return fmt.Errorf("encoding command: %w", err) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | transport, err := deps.dial(ctx, ipc.SocketDir()) | |
| 169 | + | if err != nil { | |
| 170 | + | return &exitCoded{code: 3, err: fmt.Errorf("%w: %w", errDaemonUnreachable, err)} | |
| 171 | + | } | |
| 172 | + | defer func() { _ = transport.Close() }() | |
| 173 | + | ||
| 174 | + | frame := ports.Frame{Type: ports.MsgCommand, Payload: payload} | |
| 175 | + | if err := transport.Send(frame); err != nil { | |
| 176 | + | return &exitCoded{code: 3, err: fmt.Errorf("sending command: %w", err)} | |
| 177 | + | } | |
| 178 | + | reply, err := transport.Recv() | |
| 179 | + | if err != nil { | |
| 180 | + | return &exitCoded{code: 3, err: fmt.Errorf("reading command reply: %w", err)} | |
| 181 | + | } | |
| 182 | + | if reply.Type != ports.MsgCommandResult { | |
| 183 | + | return fmt.Errorf("unexpected command reply type %d", reply.Type) | |
| 184 | + | } | |
| 185 | + | result, err := ports.UnmarshalCommandResult(reply.Payload) | |
| 186 | + | if err != nil { | |
| 187 | + | return fmt.Errorf("decoding command reply: %w", err) | |
| 188 | + | } | |
| 189 | + | if !result.OK { | |
| 190 | + | if result.Code == ports.ErrInvalidCommandArgs { | |
| 191 | + | return &exitCoded{code: 2, err: errors.New(result.Text)} | |
| 192 | + | } | |
| 193 | + | return errors.New(result.Text) | |
| 194 | + | } | |
| 195 | + | if result.Output != "" { | |
| 196 | + | if _, err := io.WriteString(deps.stdout, result.Output); err != nil { | |
| 197 | + | return err | |
| 198 | + | } | |
| 199 | + | if !strings.HasSuffix(result.Output, "\n") { | |
| 200 | + | _, err = fmt.Fprintln(deps.stdout) | |
| 201 | + | return err | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | return nil | |
| 205 | + | } | |
| @@ -0,0 +1,205 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "io" | |||
| 8 | + | "os" | |||
| 9 | + | "sort" | |||
| 10 | + | "strings" | |||
| 11 | + | "text/tabwriter" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/vev/internal/adapters/ipc" | |||
| 14 | + | "github.com/bnema/vev/internal/ports" | |||
| 15 | + | commandusecase "github.com/bnema/vev/internal/usecase/command" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | type cmdInvocation struct { | |||
| 19 | + | slug string | |||
| 20 | + | args []string | |||
| 21 | + | session string | |||
| 22 | + | self bool | |||
| 23 | + | jsonOut bool | |||
| 24 | + | help bool | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | type exitCoded struct { | |||
| 28 | + | code int | |||
| 29 | + | err error | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func (e *exitCoded) Error() string { return e.err.Error() } | |||
| 33 | + | func (e *exitCoded) Unwrap() error { return e.err } | |||
| 34 | + | ||||
| 35 | + | func exitCode(err error) int { | |||
| 36 | + | if err == nil { | |||
| 37 | + | return 0 | |||
| 38 | + | } | |||
| 39 | + | if coded, ok := errors.AsType[*exitCoded](err); ok { | |||
| 40 | + | return coded.code | |||
| 41 | + | } | |||
| 42 | + | if _, ok := errors.AsType[*usageError](err); ok { | |||
| 43 | + | return 2 | |||
| 44 | + | } | |||
| 45 | + | return 1 | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // ExitCode maps application outcomes to the CLI contract. | |||
| 49 | + | func ExitCode(err error) int { return exitCode(err) } | |||
| 50 | + | ||||
| 51 | + | func parseCmdArgs(args []string) (cmdInvocation, error) { | |||
| 52 | + | invocation := cmdInvocation{} | |||
| 53 | + | index := 0 | |||
| 54 | + | for index < len(args) && invocation.slug == "" { | |||
| 55 | + | switch args[index] { | |||
| 56 | + | case "--help", "-h": | |||
| 57 | + | invocation.help = true | |||
| 58 | + | index++ | |||
| 59 | + | case "-s": | |||
| 60 | + | if index+1 >= len(args) || args[index+1] == "" { | |||
| 61 | + | return cmdInvocation{}, usagef("`cmd -s` requires a session name") | |||
| 62 | + | } | |||
| 63 | + | invocation.session = args[index+1] | |||
| 64 | + | index += 2 | |||
| 65 | + | case "--self": | |||
| 66 | + | invocation.self = true | |||
| 67 | + | index++ | |||
| 68 | + | case "--json": | |||
| 69 | + | invocation.jsonOut = true | |||
| 70 | + | index++ | |||
| 71 | + | default: | |||
| 72 | + | if strings.HasPrefix(args[index], "-") { | |||
| 73 | + | return cmdInvocation{}, usagef("unknown flag %q for `cmd`", args[index]) | |||
| 74 | + | } | |||
| 75 | + | invocation.slug = args[index] | |||
| 76 | + | index++ | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | if invocation.slug == "" { | |||
| 80 | + | invocation.help = true | |||
| 81 | + | return invocation, nil | |||
| 82 | + | } | |||
| 83 | + | if invocation.self && invocation.session != "" { | |||
| 84 | + | return cmdInvocation{}, usagef("`cmd --self` cannot be combined with `-s`") | |||
| 85 | + | } | |||
| 86 | + | cmd, ok := commandusecase.BySlug(invocation.slug) | |||
| 87 | + | if !ok || !cmd.Scriptable { | |||
| 88 | + | return cmdInvocation{}, usagef("unknown command %q; see `vev cmd --help`", invocation.slug) | |||
| 89 | + | } | |||
| 90 | + | for _, arg := range args[index:] { | |||
| 91 | + | switch arg { | |||
| 92 | + | case "--help", "-h": | |||
| 93 | + | invocation.help = true | |||
| 94 | + | return invocation, nil | |||
| 95 | + | case "--json": | |||
| 96 | + | invocation.jsonOut = true | |||
| 97 | + | default: | |||
| 98 | + | invocation.args = append(invocation.args, arg) | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | return invocation, nil | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func cmdHelp(invocation cmdInvocation) string { | |||
| 105 | + | if invocation.slug != "" { | |||
| 106 | + | cmd, _ := commandusecase.BySlug(invocation.slug) | |||
| 107 | + | return fmt.Sprintf("usage: vev cmd [-s <session>] [--self] %s\n\n%s", cmd.Usage, cmd.Desc) | |||
| 108 | + | } | |||
| 109 | + | var out strings.Builder | |||
| 110 | + | out.WriteString("usage: vev cmd [-s <session>] [--self] <command> [args]\n\ncommands:\n") | |||
| 111 | + | writer := tabwriter.NewWriter(&out, 0, 4, 2, ' ', 0) | |||
| 112 | + | commands := commandusecase.Registry() | |||
| 113 | + | sort.Slice(commands, func(i, j int) bool { return commands[i].Slug < commands[j].Slug }) | |||
| 114 | + | for _, cmd := range commands { | |||
| 115 | + | if cmd.Scriptable { | |||
| 116 | + | _, _ = fmt.Fprintf(writer, " %s\t%s\n", cmd.Usage, cmd.Desc) | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | _ = writer.Flush() | |||
| 120 | + | return out.String() | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | var errDaemonUnreachable = errors.New("daemon not running") | |||
| 124 | + | ||||
| 125 | + | type cmdDeps struct { | |||
| 126 | + | stdout io.Writer | |||
| 127 | + | getenv func(string) string | |||
| 128 | + | dial func(context.Context, string) (ports.Transport, error) | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func runCmd(ctx context.Context, invocation cmdInvocation) error { | |||
| 132 | + | return runCmdWithDeps(ctx, invocation, cmdDeps{stdout: os.Stdout, getenv: os.Getenv, dial: realDial}) | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) error { | |||
| 136 | + | if invocation.help { | |||
| 137 | + | _, err := fmt.Fprintln(deps.stdout, cmdHelp(invocation)) | |||
| 138 | + | return err | |||
| 139 | + | } | |||
| 140 | + | request := ports.CommandRequest{ | |||
| 141 | + | Version: ports.ProtocolVersion, | |||
| 142 | + | Self: invocation.self, | |||
| 143 | + | Slug: invocation.slug, | |||
| 144 | + | Args: invocation.args, | |||
| 145 | + | TargetSession: invocation.session, | |||
| 146 | + | JSON: invocation.jsonOut, | |||
| 147 | + | } | |||
| 148 | + | if identity, ok := parseVEVEnv(deps.getenv("VEV")); ok { | |||
| 149 | + | if request.TargetSession == "" { | |||
| 150 | + | request.TargetSession = identity.session | |||
| 151 | + | } | |||
| 152 | + | if invocation.self || invocation.session == "" { | |||
| 153 | + | request.TargetTab = identity.tab | |||
| 154 | + | request.TargetPane = identity.pane | |||
| 155 | + | } | |||
| 156 | + | } else if invocation.self { | |||
| 157 | + | return usagef("--self requires running inside a vev pane") | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | payload, err := ports.MarshalCommandRequest(request) | |||
| 161 | + | if errors.Is(err, ports.ErrTooManyCommandArgs) { | |||
| 162 | + | return usagef("too many command arguments") | |||
| 163 | + | } | |||
| 164 | + | if err != nil { | |||
| 165 | + | return fmt.Errorf("encoding command: %w", err) | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | transport, err := deps.dial(ctx, ipc.SocketDir()) | |||
| 169 | + | if err != nil { | |||
| 170 | + | return &exitCoded{code: 3, err: fmt.Errorf("%w: %w", errDaemonUnreachable, err)} | |||
| 171 | + | } | |||
| 172 | + | defer func() { _ = transport.Close() }() | |||
| 173 | + | ||||
| 174 | + | frame := ports.Frame{Type: ports.MsgCommand, Payload: payload} | |||
| 175 | + | if err := transport.Send(frame); err != nil { | |||
| 176 | + | return &exitCoded{code: 3, err: fmt.Errorf("sending command: %w", err)} | |||
| 177 | + | } | |||
| 178 | + | reply, err := transport.Recv() | |||
| 179 | + | if err != nil { | |||
| 180 | + | return &exitCoded{code: 3, err: fmt.Errorf("reading command reply: %w", err)} | |||
| 181 | + | } | |||
| 182 | + | if reply.Type != ports.MsgCommandResult { | |||
| 183 | + | return fmt.Errorf("unexpected command reply type %d", reply.Type) | |||
| 184 | + | } | |||
| 185 | + | result, err := ports.UnmarshalCommandResult(reply.Payload) | |||
| 186 | + | if err != nil { | |||
| 187 | + | return fmt.Errorf("decoding command reply: %w", err) | |||
| 188 | + | } | |||
| 189 | + | if !result.OK { | |||
| 190 | + | if result.Code == ports.ErrInvalidCommandArgs { | |||
| 191 | + | return &exitCoded{code: 2, err: errors.New(result.Text)} | |||
| 192 | + | } | |||
| 193 | + | return errors.New(result.Text) | |||
| 194 | + | } | |||
| 195 | + | if result.Output != "" { | |||
| 196 | + | if _, err := io.WriteString(deps.stdout, result.Output); err != nil { | |||
| 197 | + | return err | |||
| 198 | + | } | |||
| 199 | + | if !strings.HasSuffix(result.Output, "\n") { | |||
| 200 | + | _, err = fmt.Fprintln(deps.stdout) | |||
| 201 | + | return err | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | return nil | |||
| 205 | + | } | |||
A internal/app/cmd_test.go +261 -0
| @@ -0,0 +1,261 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "io" | |
| 7 | + | "math" | |
| 8 | + | "reflect" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/ports" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestParseCmdArgs(t *testing.T) { | |
| 16 | + | tests := []struct { | |
| 17 | + | name string | |
| 18 | + | args []string | |
| 19 | + | want cmdInvocation | |
| 20 | + | wantErr bool | |
| 21 | + | }{ | |
| 22 | + | {"bare cmd is help", []string{"cmd"}, cmdInvocation{help: true}, false}, | |
| 23 | + | {"cmd help", []string{"cmd", "--help"}, cmdInvocation{help: true}, false}, | |
| 24 | + | {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false}, | |
| 25 | + | {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false}, | |
| 26 | + | {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false}, | |
| 27 | + | {"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false}, | |
| 28 | + | {"self flag", []string{"cmd", "--self", "split-right"}, cmdInvocation{slug: "split-right", self: true}, false}, | |
| 29 | + | {"self and session conflict", []string{"cmd", "-s", "other", "--self", "split-right"}, cmdInvocation{}, true}, | |
| 30 | + | {"passthrough args", []string{"cmd", "toast", "-l", "warn", "hello"}, cmdInvocation{slug: "toast", args: []string{"-l", "warn", "hello"}}, false}, | |
| 31 | + | {"unknown slug", []string{"cmd", "frobnicate"}, cmdInvocation{}, true}, | |
| 32 | + | {"non-scriptable slug", []string{"cmd", "detach"}, cmdInvocation{}, true}, | |
| 33 | + | {"session missing value", []string{"cmd", "-s"}, cmdInvocation{}, true}, | |
| 34 | + | {"unknown leading flag", []string{"cmd", "--wat"}, cmdInvocation{}, true}, | |
| 35 | + | } | |
| 36 | + | for _, tt := range tests { | |
| 37 | + | t.Run(tt.name, func(t *testing.T) { | |
| 38 | + | got, err := parseArgs(tt.args) | |
| 39 | + | if tt.wantErr { | |
| 40 | + | var usage *usageError | |
| 41 | + | if !errors.As(err, &usage) { | |
| 42 | + | t.Fatalf("parseArgs(%q) error = %v, want usage error", tt.args, err) | |
| 43 | + | } | |
| 44 | + | return | |
| 45 | + | } | |
| 46 | + | if err != nil { | |
| 47 | + | t.Fatalf("parseArgs(%q): %v", tt.args, err) | |
| 48 | + | } | |
| 49 | + | if got.kind != kindCmd || !reflect.DeepEqual(got.cmd, tt.want) { | |
| 50 | + | t.Fatalf("parseArgs(%q) = kind %v, cmd %#v, want %#v", tt.args, got.kind, got.cmd, tt.want) | |
| 51 | + | } | |
| 52 | + | }) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestExitCodes(t *testing.T) { | |
| 57 | + | tests := []struct { | |
| 58 | + | name string | |
| 59 | + | err error | |
| 60 | + | want int | |
| 61 | + | }{ | |
| 62 | + | {"success", nil, 0}, | |
| 63 | + | {"command", errors.New("command failed"), 1}, | |
| 64 | + | {"usage", &usageError{msg: "bad args"}, 2}, | |
| 65 | + | {"unreachable", &exitCoded{code: 3, err: errDaemonUnreachable}, 3}, | |
| 66 | + | } | |
| 67 | + | for _, tt := range tests { | |
| 68 | + | t.Run(tt.name, func(t *testing.T) { | |
| 69 | + | if got := ExitCode(tt.err); got != tt.want { | |
| 70 | + | t.Fatalf("ExitCode(%v) = %d, want %d", tt.err, got, tt.want) | |
| 71 | + | } | |
| 72 | + | }) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) { | |
| 77 | + | called := false | |
| 78 | + | out := new(strings.Builder) | |
| 79 | + | err := runCmdWithDeps(context.Background(), cmdInvocation{help: true}, cmdDeps{ | |
| 80 | + | stdout: out, | |
| 81 | + | getenv: func(string) string { return "" }, | |
| 82 | + | dial: func(context.Context, string) (ports.Transport, error) { | |
| 83 | + | called = true | |
| 84 | + | return nil, errors.New("must not dial") | |
| 85 | + | }, | |
| 86 | + | }) | |
| 87 | + | if err != nil { | |
| 88 | + | t.Fatalf("runCmdWithDeps(help): %v", err) | |
| 89 | + | } | |
| 90 | + | if called { | |
| 91 | + | t.Fatal("help dialed daemon") | |
| 92 | + | } | |
| 93 | + | for _, want := range []string{"usage: vev cmd", "split-right", "list-panes"} { | |
| 94 | + | if !strings.Contains(out.String(), want) { | |
| 95 | + | t.Fatalf("help %q missing %q", out.String(), want) | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | if strings.Contains(out.String(), "session-picker") { | |
| 99 | + | t.Fatalf("help leaked non-scriptable command: %q", out.String()) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) { | |
| 104 | + | transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true, Output: "done"})}} | |
| 105 | + | out := new(strings.Builder) | |
| 106 | + | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right", self: true}, cmdDeps{ | |
| 107 | + | stdout: out, | |
| 108 | + | getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" }, | |
| 109 | + | dial: func(context.Context, string) (ports.Transport, error) { return transport, nil }, | |
| 110 | + | }) | |
| 111 | + | if err != nil { | |
| 112 | + | t.Fatalf("runCmdWithDeps: %v", err) | |
| 113 | + | } | |
| 114 | + | if len(transport.sent) != 1 || transport.recvCalls != 1 || transport.closeCalls != 1 { | |
| 115 | + | t.Fatalf("transport calls: sent=%d recv=%d close=%d", len(transport.sent), transport.recvCalls, transport.closeCalls) | |
| 116 | + | } | |
| 117 | + | req, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload) | |
| 118 | + | if err != nil { | |
| 119 | + | t.Fatalf("decode request: %v", err) | |
| 120 | + | } | |
| 121 | + | if transport.sent[0].Type != ports.MsgCommand || req.Slug != "split-right" || !req.Self || req.TargetSession != "old" || req.TargetTab != "t_abc" || req.TargetPane != "p_def" { | |
| 122 | + | t.Fatalf("request = type %d %+v", transport.sent[0].Type, req) | |
| 123 | + | } | |
| 124 | + | if out.String() != "done\n" { | |
| 125 | + | t.Fatalf("output = %q", out.String()) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestRunCmdVEVWithoutSelfUsesIDsOnlyAsSessionLocator(t *testing.T) { | |
| 130 | + | transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}} | |
| 131 | + | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{ | |
| 132 | + | stdout: io.Discard, | |
| 133 | + | getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" }, | |
| 134 | + | dial: func(context.Context, string) (ports.Transport, error) { return transport, nil }, | |
| 135 | + | }) | |
| 136 | + | if err != nil { | |
| 137 | + | t.Fatalf("runCmdWithDeps: %v", err) | |
| 138 | + | } | |
| 139 | + | req, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload) | |
| 140 | + | if err != nil { | |
| 141 | + | t.Fatalf("decode request: %v", err) | |
| 142 | + | } | |
| 143 | + | if req.Self || req.TargetSession != "old" || req.TargetTab != "t_abc" || req.TargetPane != "p_def" { | |
| 144 | + | t.Fatalf("VEV locator request = %+v", req) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestRunCmdExplicitSessionDoesNotUseEnvIDsWithoutSelf(t *testing.T) { | |
| 149 | + | transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}} | |
| 150 | + | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "new-tab", session: "current"}, cmdDeps{ | |
| 151 | + | stdout: io.Discard, | |
| 152 | + | getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" }, | |
| 153 | + | dial: func(context.Context, string) (ports.Transport, error) { return transport, nil }, | |
| 154 | + | }) | |
| 155 | + | if err != nil { | |
| 156 | + | t.Fatalf("runCmdWithDeps: %v", err) | |
| 157 | + | } | |
| 158 | + | req, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload) | |
| 159 | + | if err != nil { | |
| 160 | + | t.Fatalf("decode request: %v", err) | |
| 161 | + | } | |
| 162 | + | if req.TargetSession != "current" || req.TargetTab != "" || req.TargetPane != "" { | |
| 163 | + | t.Fatalf("explicit targeting request = %+v", req) | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func TestRunCmdRejectsTooManyArgumentsBeforeDialing(t *testing.T) { | |
| 168 | + | dialed := false | |
| 169 | + | err := runCmdWithDeps(context.Background(), cmdInvocation{ | |
| 170 | + | slug: "toast", | |
| 171 | + | args: make([]string, math.MaxUint16+1), | |
| 172 | + | }, cmdDeps{ | |
| 173 | + | stdout: io.Discard, | |
| 174 | + | getenv: func(string) string { return "" }, | |
| 175 | + | dial: func(context.Context, string) (ports.Transport, error) { | |
| 176 | + | dialed = true | |
| 177 | + | return nil, errors.New("must not dial") | |
| 178 | + | }, | |
| 179 | + | }) | |
| 180 | + | ||
| 181 | + | if ExitCode(err) != 2 { | |
| 182 | + | t.Fatalf("error=%v code=%d, want usage error/2", err, ExitCode(err)) | |
| 183 | + | } | |
| 184 | + | if dialed { | |
| 185 | + | t.Fatal("oversized command dialed daemon") | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func TestRunCmdDoesNotAutostartAndClassifiesDialFailure(t *testing.T) { | |
| 190 | + | dials := 0 | |
| 191 | + | dialErr := errors.New("unreachable") | |
| 192 | + | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{ | |
| 193 | + | stdout: io.Discard, | |
| 194 | + | getenv: func(string) string { return "" }, | |
| 195 | + | dial: func(context.Context, string) (ports.Transport, error) { | |
| 196 | + | dials++ | |
| 197 | + | return nil, dialErr | |
| 198 | + | }, | |
| 199 | + | }) | |
| 200 | + | if ExitCode(err) != 3 || dials != 1 { | |
| 201 | + | t.Fatalf("error=%v code=%d dials=%d, want unreachable/3 and one dial", err, ExitCode(err), dials) | |
| 202 | + | } | |
| 203 | + | if !errors.Is(err, dialErr) { | |
| 204 | + | t.Fatalf("error=%v does not wrap dial error", err) | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func TestRunCmdClassifiesDaemonCommandErrors(t *testing.T) { | |
| 209 | + | tests := []struct { | |
| 210 | + | name string | |
| 211 | + | result ports.CommandResult | |
| 212 | + | wantCode int | |
| 213 | + | }{ | |
| 214 | + | { | |
| 215 | + | name: "invalid command arguments are usage errors", | |
| 216 | + | result: ports.CommandResult{Code: ports.ErrInvalidCommandArgs, Text: "usage: toast [-l level] <message>"}, | |
| 217 | + | wantCode: 2, | |
| 218 | + | }, | |
| 219 | + | { | |
| 220 | + | name: "missing runtime target is a command failure", | |
| 221 | + | result: ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "no live sessions"}, | |
| 222 | + | wantCode: 1, | |
| 223 | + | }, | |
| 224 | + | } | |
| 225 | + | for _, tt := range tests { | |
| 226 | + | t.Run(tt.name, func(t *testing.T) { | |
| 227 | + | transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(tt.result)}} | |
| 228 | + | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{ | |
| 229 | + | stdout: io.Discard, | |
| 230 | + | getenv: func(string) string { return "" }, | |
| 231 | + | dial: func(context.Context, string) (ports.Transport, error) { return transport, nil }, | |
| 232 | + | }) | |
| 233 | + | if err == nil || ExitCode(err) != tt.wantCode || err.Error() != tt.result.Text { | |
| 234 | + | t.Fatalf("error=%v code=%d, want message %q and code %d", err, ExitCode(err), tt.result.Text, tt.wantCode) | |
| 235 | + | } | |
| 236 | + | }) | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | type cmdTestTransport struct { | |
| 241 | + | sent []ports.Frame | |
| 242 | + | recv ports.Frame | |
| 243 | + | recvErr error | |
| 244 | + | recvCalls int | |
| 245 | + | closeCalls int | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func (t *cmdTestTransport) Send(frame ports.Frame) error { | |
| 249 | + | t.sent = append(t.sent, frame) | |
| 250 | + | return nil | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func (t *cmdTestTransport) Recv() (ports.Frame, error) { | |
| 254 | + | t.recvCalls++ | |
| 255 | + | return t.recv, t.recvErr | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | func (t *cmdTestTransport) Close() error { | |
| 259 | + | t.closeCalls++ | |
| 260 | + | return nil | |
| 261 | + | } | |
| @@ -0,0 +1,261 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "io" | |||
| 7 | + | "math" | |||
| 8 | + | "reflect" | |||
| 9 | + | "strings" | |||
| 10 | + | "testing" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/ports" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestParseCmdArgs(t *testing.T) { | |||
| 16 | + | tests := []struct { | |||
| 17 | + | name string | |||
| 18 | + | args []string | |||
| 19 | + | want cmdInvocation | |||
| 20 | + | wantErr bool | |||
| 21 | + | }{ | |||
| 22 | + | {"bare cmd is help", []string{"cmd"}, cmdInvocation{help: true}, false}, | |||
| 23 | + | {"cmd help", []string{"cmd", "--help"}, cmdInvocation{help: true}, false}, | |||
| 24 | + | {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false}, | |||
| 25 | + | {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false}, | |||
| 26 | + | {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false}, | |||
| 27 | + | {"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false}, | |||
| 28 | + | {"self flag", []string{"cmd", "--self", "split-right"}, cmdInvocation{slug: "split-right", self: true}, false}, | |||
| 29 | + | {"self and session conflict", []string{"cmd", "-s", "other", "--self", "split-right"}, cmdInvocation{}, true}, | |||
| 30 | + | {"passthrough args", []string{"cmd", "toast", "-l", "warn", "hello"}, cmdInvocation{slug: "toast", args: []string{"-l", "warn", "hello"}}, false}, | |||
| 31 | + | {"unknown slug", []string{"cmd", "frobnicate"}, cmdInvocation{}, true}, | |||
| 32 | + | {"non-scriptable slug", []string{"cmd", "detach"}, cmdInvocation{}, true}, | |||
| 33 | + | {"session missing value", []string{"cmd", "-s"}, cmdInvocation{}, true}, | |||
| 34 | + | {"unknown leading flag", []string{"cmd", "--wat"}, cmdInvocation{}, true}, | |||
| 35 | + | } | |||
| 36 | + | for _, tt := range tests { | |||
| 37 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 38 | + | got, err := parseArgs(tt.args) | |||
| 39 | + | if tt.wantErr { | |||
| 40 | + | var usage *usageError | |||
| 41 | + | if !errors.As(err, &usage) { | |||
| 42 | + | t.Fatalf("parseArgs(%q) error = %v, want usage error", tt.args, err) | |||
| 43 | + | } | |||
| 44 | + | return | |||
| 45 | + | } | |||
| 46 | + | if err != nil { | |||
| 47 | + | t.Fatalf("parseArgs(%q): %v", tt.args, err) | |||
| 48 | + | } | |||
| 49 | + | if got.kind != kindCmd || !reflect.DeepEqual(got.cmd, tt.want) { | |||
| 50 | + | t.Fatalf("parseArgs(%q) = kind %v, cmd %#v, want %#v", tt.args, got.kind, got.cmd, tt.want) | |||
| 51 | + | } | |||
| 52 | + | }) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestExitCodes(t *testing.T) { | |||
| 57 | + | tests := []struct { | |||
| 58 | + | name string | |||
| 59 | + | err error | |||
| 60 | + | want int | |||
| 61 | + | }{ | |||
| 62 | + | {"success", nil, 0}, | |||
| 63 | + | {"command", errors.New("command failed"), 1}, | |||
| 64 | + | {"usage", &usageError{msg: "bad args"}, 2}, | |||
| 65 | + | {"unreachable", &exitCoded{code: 3, err: errDaemonUnreachable}, 3}, | |||
| 66 | + | } | |||
| 67 | + | for _, tt := range tests { | |||
| 68 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 69 | + | if got := ExitCode(tt.err); got != tt.want { | |||
| 70 | + | t.Fatalf("ExitCode(%v) = %d, want %d", tt.err, got, tt.want) | |||
| 71 | + | } | |||
| 72 | + | }) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) { | |||
| 77 | + | called := false | |||
| 78 | + | out := new(strings.Builder) | |||
| 79 | + | err := runCmdWithDeps(context.Background(), cmdInvocation{help: true}, cmdDeps{ | |||
| 80 | + | stdout: out, | |||
| 81 | + | getenv: func(string) string { return "" }, | |||
| 82 | + | dial: func(context.Context, string) (ports.Transport, error) { | |||
| 83 | + | called = true | |||
| 84 | + | return nil, errors.New("must not dial") | |||
| 85 | + | }, | |||
| 86 | + | }) | |||
| 87 | + | if err != nil { | |||
| 88 | + | t.Fatalf("runCmdWithDeps(help): %v", err) | |||
| 89 | + | } | |||
| 90 | + | if called { | |||
| 91 | + | t.Fatal("help dialed daemon") | |||
| 92 | + | } | |||
| 93 | + | for _, want := range []string{"usage: vev cmd", "split-right", "list-panes"} { | |||
| 94 | + | if !strings.Contains(out.String(), want) { | |||
| 95 | + | t.Fatalf("help %q missing %q", out.String(), want) | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | if strings.Contains(out.String(), "session-picker") { | |||
| 99 | + | t.Fatalf("help leaked non-scriptable command: %q", out.String()) | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) { | |||
| 104 | + | transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true, Output: "done"})}} | |||
| 105 | + | out := new(strings.Builder) | |||
| 106 | + | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right", self: true}, cmdDeps{ | |||
| 107 | + | stdout: out, | |||
| 108 | + | getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" }, | |||
| 109 | + | dial: func(context.Context, string) (ports.Transport, error) { return transport, nil }, | |||
| 110 | + | }) | |||
| 111 | + | if err != nil { | |||
| 112 | + | t.Fatalf("runCmdWithDeps: %v", err) | |||
| 113 | + | } | |||
| 114 | + | if len(transport.sent) != 1 || transport.recvCalls != 1 || transport.closeCalls != 1 { | |||
| 115 | + | t.Fatalf("transport calls: sent=%d recv=%d close=%d", len(transport.sent), transport.recvCalls, transport.closeCalls) | |||
| 116 | + | } | |||
| 117 | + | req, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload) | |||
| 118 | + | if err != nil { | |||
| 119 | + | t.Fatalf("decode request: %v", err) | |||
| 120 | + | } | |||
| 121 | + | if transport.sent[0].Type != ports.MsgCommand || req.Slug != "split-right" || !req.Self || req.TargetSession != "old" || req.TargetTab != "t_abc" || req.TargetPane != "p_def" { | |||
| 122 | + | t.Fatalf("request = type %d %+v", transport.sent[0].Type, req) | |||
| 123 | + | } | |||
| 124 | + | if out.String() != "done\n" { | |||
| 125 | + | t.Fatalf("output = %q", out.String()) | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func TestRunCmdVEVWithoutSelfUsesIDsOnlyAsSessionLocator(t *testing.T) { | |||
| 130 | + | transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}} | |||
| 131 | + | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{ | |||
| 132 | + | stdout: io.Discard, | |||
| 133 | + | getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" }, | |||
| 134 | + | dial: func(context.Context, string) (ports.Transport, error) { return transport, nil }, | |||
| 135 | + | }) | |||
| 136 | + | if err != nil { | |||
| 137 | + | t.Fatalf("runCmdWithDeps: %v", err) | |||
| 138 | + | } | |||
| 139 | + | req, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload) | |||
| 140 | + | if err != nil { | |||
| 141 | + | t.Fatalf("decode request: %v", err) | |||
| 142 | + | } | |||
| 143 | + | if req.Self || req.TargetSession != "old" || req.TargetTab != "t_abc" || req.TargetPane != "p_def" { | |||
| 144 | + | t.Fatalf("VEV locator request = %+v", req) | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func TestRunCmdExplicitSessionDoesNotUseEnvIDsWithoutSelf(t *testing.T) { | |||
| 149 | + | transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}} | |||
| 150 | + | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "new-tab", session: "current"}, cmdDeps{ | |||
| 151 | + | stdout: io.Discard, | |||
| 152 | + | getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" }, | |||
| 153 | + | dial: func(context.Context, string) (ports.Transport, error) { return transport, nil }, | |||
| 154 | + | }) | |||
| 155 | + | if err != nil { | |||
| 156 | + | t.Fatalf("runCmdWithDeps: %v", err) | |||
| 157 | + | } | |||
| 158 | + | req, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload) | |||
| 159 | + | if err != nil { | |||
| 160 | + | t.Fatalf("decode request: %v", err) | |||
| 161 | + | } | |||
| 162 | + | if req.TargetSession != "current" || req.TargetTab != "" || req.TargetPane != "" { | |||
| 163 | + | t.Fatalf("explicit targeting request = %+v", req) | |||
| 164 | + | } | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func TestRunCmdRejectsTooManyArgumentsBeforeDialing(t *testing.T) { | |||
| 168 | + | dialed := false | |||
| 169 | + | err := runCmdWithDeps(context.Background(), cmdInvocation{ | |||
| 170 | + | slug: "toast", | |||
| 171 | + | args: make([]string, math.MaxUint16+1), | |||
| 172 | + | }, cmdDeps{ | |||
| 173 | + | stdout: io.Discard, | |||
| 174 | + | getenv: func(string) string { return "" }, | |||
| 175 | + | dial: func(context.Context, string) (ports.Transport, error) { | |||
| 176 | + | dialed = true | |||
| 177 | + | return nil, errors.New("must not dial") | |||
| 178 | + | }, | |||
| 179 | + | }) | |||
| 180 | + | ||||
| 181 | + | if ExitCode(err) != 2 { | |||
| 182 | + | t.Fatalf("error=%v code=%d, want usage error/2", err, ExitCode(err)) | |||
| 183 | + | } | |||
| 184 | + | if dialed { | |||
| 185 | + | t.Fatal("oversized command dialed daemon") | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func TestRunCmdDoesNotAutostartAndClassifiesDialFailure(t *testing.T) { | |||
| 190 | + | dials := 0 | |||
| 191 | + | dialErr := errors.New("unreachable") | |||
| 192 | + | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{ | |||
| 193 | + | stdout: io.Discard, | |||
| 194 | + | getenv: func(string) string { return "" }, | |||
| 195 | + | dial: func(context.Context, string) (ports.Transport, error) { | |||
| 196 | + | dials++ | |||
| 197 | + | return nil, dialErr | |||
| 198 | + | }, | |||
| 199 | + | }) | |||
| 200 | + | if ExitCode(err) != 3 || dials != 1 { | |||
| 201 | + | t.Fatalf("error=%v code=%d dials=%d, want unreachable/3 and one dial", err, ExitCode(err), dials) | |||
| 202 | + | } | |||
| 203 | + | if !errors.Is(err, dialErr) { | |||
| 204 | + | t.Fatalf("error=%v does not wrap dial error", err) | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func TestRunCmdClassifiesDaemonCommandErrors(t *testing.T) { | |||
| 209 | + | tests := []struct { | |||
| 210 | + | name string | |||
| 211 | + | result ports.CommandResult | |||
| 212 | + | wantCode int | |||
| 213 | + | }{ | |||
| 214 | + | { | |||
| 215 | + | name: "invalid command arguments are usage errors", | |||
| 216 | + | result: ports.CommandResult{Code: ports.ErrInvalidCommandArgs, Text: "usage: toast [-l level] <message>"}, | |||
| 217 | + | wantCode: 2, | |||
| 218 | + | }, | |||
| 219 | + | { | |||
| 220 | + | name: "missing runtime target is a command failure", | |||
| 221 | + | result: ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "no live sessions"}, | |||
| 222 | + | wantCode: 1, | |||
| 223 | + | }, | |||
| 224 | + | } | |||
| 225 | + | for _, tt := range tests { | |||
| 226 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 227 | + | transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(tt.result)}} | |||
| 228 | + | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{ | |||
| 229 | + | stdout: io.Discard, | |||
| 230 | + | getenv: func(string) string { return "" }, | |||
| 231 | + | dial: func(context.Context, string) (ports.Transport, error) { return transport, nil }, | |||
| 232 | + | }) | |||
| 233 | + | if err == nil || ExitCode(err) != tt.wantCode || err.Error() != tt.result.Text { | |||
| 234 | + | t.Fatalf("error=%v code=%d, want message %q and code %d", err, ExitCode(err), tt.result.Text, tt.wantCode) | |||
| 235 | + | } | |||
| 236 | + | }) | |||
| 237 | + | } | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | type cmdTestTransport struct { | |||
| 241 | + | sent []ports.Frame | |||
| 242 | + | recv ports.Frame | |||
| 243 | + | recvErr error | |||
| 244 | + | recvCalls int | |||
| 245 | + | closeCalls int | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | func (t *cmdTestTransport) Send(frame ports.Frame) error { | |||
| 249 | + | t.sent = append(t.sent, frame) | |||
| 250 | + | return nil | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | func (t *cmdTestTransport) Recv() (ports.Frame, error) { | |||
| 254 | + | t.recvCalls++ | |||
| 255 | + | return t.recv, t.recvErr | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | func (t *cmdTestTransport) Close() error { | |||
| 259 | + | t.closeCalls++ | |||
| 260 | + | return nil | |||
| 261 | + | } | |||
M internal/app/run.go +11 -0
| @@ -59,6 +59,7 @@ const ( | |||
| 59 | 59 | kindAttach cmdKind = iota // ephemeral/new/attach — distinguished by intent | |
| 60 | 60 | kindList | |
| 61 | 61 | kindKill | |
| 62 | + | kindCmd | |
| 62 | 63 | kindDaemon | |
| 63 | 64 | kindStdio | |
| 64 | 65 | kindUDPBootstrap | |
| @@ -76,6 +77,7 @@ type command struct { | |||
| 76 | 77 | remoteTarget string | |
| 77 | 78 | killAll bool | |
| 78 | 79 | killDaemon bool | |
| 80 | + | cmd cmdInvocation | |
| 79 | 81 | } | |
| 80 | 82 | ||
| 81 | 83 | // usageError is a user-facing argument error; the app prints it (with usage) | |
| @@ -100,6 +102,7 @@ usage: | |||
| 100 | 102 | vev kill <name> kill a session | |
| 101 | 103 | vev kill --all kill all sessions and stop the daemon | |
| 102 | 104 | vev kill --daemon stop the active vev daemon | |
| 105 | + | vev cmd <command> run a control command (vev cmd --help) | |
| 103 | 106 | vev --help show this help | |
| 104 | 107 | vev --version show version` | |
| 105 | 108 | ||
| @@ -194,6 +197,12 @@ func parseArgs(args []string) (command, error) { | |||
| 194 | 197 | return cmd, nil | |
| 195 | 198 | case "ls", "list": | |
| 196 | 199 | return command{kind: kindList}, nil | |
| 200 | + | case "cmd": | |
| 201 | + | invocation, err := parseCmdArgs(args[1:]) | |
| 202 | + | if err != nil { | |
| 203 | + | return command{}, err | |
| 204 | + | } | |
| 205 | + | return command{kind: kindCmd, cmd: invocation}, nil | |
| 197 | 206 | case "kill": | |
| 198 | 207 | if len(args) < 2 || args[1] == "" { | |
| 199 | 208 | return command{}, usagef("`kill` requires a session name, --all, or --daemon") | |
| @@ -245,6 +254,8 @@ func dispatch(ctx context.Context, cmd command) error { | |||
| 245 | 254 | return runList(ctx) | |
| 246 | 255 | case kindKill: | |
| 247 | 256 | return runKill(ctx, cmd.name, cmd.killAll, cmd.killDaemon) | |
| 257 | + | case kindCmd: | |
| 258 | + | return runCmd(ctx, cmd.cmd) | |
| 248 | 259 | case kindAttach: | |
| 249 | 260 | return runAttach(ctx, cmd.intent, cmd.name, cmd.remoteTarget) | |
| 250 | 261 | default: | |
| @@ -59,6 +59,7 @@ const ( | |||||
| 59 | kindAttach cmdKind = iota // ephemeral/new/attach — distinguished by intent | 59 | kindAttach cmdKind = iota // ephemeral/new/attach — distinguished by intent | ||
| 60 | kindList | 60 | kindList | ||
| 61 | kindKill | 61 | kindKill | ||
| 62 | + | kindCmd | |||
| 62 | kindDaemon | 63 | kindDaemon | ||
| 63 | kindStdio | 64 | kindStdio | ||
| 64 | kindUDPBootstrap | 65 | kindUDPBootstrap | ||
| @@ -76,6 +77,7 @@ type command struct { | |||||
| 76 | remoteTarget string | 77 | remoteTarget string | ||
| 77 | killAll bool | 78 | killAll bool | ||
| 78 | killDaemon bool | 79 | killDaemon bool | ||
| 80 | + | cmd cmdInvocation | |||
| 79 | } | 81 | } | ||
| 80 | 82 | ||||
| 81 | // usageError is a user-facing argument error; the app prints it (with usage) | 83 | // usageError is a user-facing argument error; the app prints it (with usage) | ||
| @@ -100,6 +102,7 @@ usage: | |||||
| 100 | vev kill <name> kill a session | 102 | vev kill <name> kill a session | ||
| 101 | vev kill --all kill all sessions and stop the daemon | 103 | vev kill --all kill all sessions and stop the daemon | ||
| 102 | vev kill --daemon stop the active vev daemon | 104 | vev kill --daemon stop the active vev daemon | ||
| 105 | + | vev cmd <command> run a control command (vev cmd --help) | |||
| 103 | vev --help show this help | 106 | vev --help show this help | ||
| 104 | vev --version show version` | 107 | vev --version show version` | ||
| 105 | 108 | ||||
| @@ -194,6 +197,12 @@ func parseArgs(args []string) (command, error) { | |||||
| 194 | return cmd, nil | 197 | return cmd, nil | ||
| 195 | case "ls", "list": | 198 | case "ls", "list": | ||
| 196 | return command{kind: kindList}, nil | 199 | return command{kind: kindList}, nil | ||
| 200 | + | case "cmd": | |||
| 201 | + | invocation, err := parseCmdArgs(args[1:]) | |||
| 202 | + | if err != nil { | |||
| 203 | + | return command{}, err | |||
| 204 | + | } | |||
| 205 | + | return command{kind: kindCmd, cmd: invocation}, nil | |||
| 197 | case "kill": | 206 | case "kill": | ||
| 198 | if len(args) < 2 || args[1] == "" { | 207 | if len(args) < 2 || args[1] == "" { | ||
| 199 | return command{}, usagef("`kill` requires a session name, --all, or --daemon") | 208 | return command{}, usagef("`kill` requires a session name, --all, or --daemon") | ||
| @@ -245,6 +254,8 @@ func dispatch(ctx context.Context, cmd command) error { | |||||
| 245 | return runList(ctx) | 254 | return runList(ctx) | ||
| 246 | case kindKill: | 255 | case kindKill: | ||
| 247 | return runKill(ctx, cmd.name, cmd.killAll, cmd.killDaemon) | 256 | return runKill(ctx, cmd.name, cmd.killAll, cmd.killDaemon) | ||
| 257 | + | case kindCmd: | |||
| 258 | + | return runCmd(ctx, cmd.cmd) | |||
| 248 | case kindAttach: | 259 | case kindAttach: | ||
| 249 | return runAttach(ctx, cmd.intent, cmd.name, cmd.remoteTarget) | 260 | return runAttach(ctx, cmd.intent, cmd.name, cmd.remoteTarget) | ||
| 250 | default: | 261 | default: | ||
A internal/app/vevenv.go +87 -0
| @@ -0,0 +1,87 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import "strings" | |
| 4 | + | ||
| 5 | + | type vevEnv struct { | |
| 6 | + | session string | |
| 7 | + | tab string | |
| 8 | + | pane string | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | // parseVEVEnv strictly parses the identity injected into pane PTYs. All three | |
| 12 | + | // unique fields are required and components use the daemon's uppercase %XX | |
| 13 | + | // escaping. | |
| 14 | + | func parseVEVEnv(value string) (vevEnv, bool) { | |
| 15 | + | if value == "" { | |
| 16 | + | return vevEnv{}, false | |
| 17 | + | } | |
| 18 | + | var out vevEnv | |
| 19 | + | seen := make(map[string]bool, 3) | |
| 20 | + | for part := range strings.SplitSeq(value, ",") { | |
| 21 | + | key, encoded, found := strings.Cut(part, "=") | |
| 22 | + | if !found || seen[key] { | |
| 23 | + | return vevEnv{}, false | |
| 24 | + | } | |
| 25 | + | decoded, ok := unescapeVEVComponent(encoded) | |
| 26 | + | if !ok || decoded == "" { | |
| 27 | + | return vevEnv{}, false | |
| 28 | + | } | |
| 29 | + | switch key { | |
| 30 | + | case "session": | |
| 31 | + | out.session = decoded | |
| 32 | + | case "tab": | |
| 33 | + | out.tab = decoded | |
| 34 | + | case "pane": | |
| 35 | + | out.pane = decoded | |
| 36 | + | default: | |
| 37 | + | return vevEnv{}, false | |
| 38 | + | } | |
| 39 | + | seen[key] = true | |
| 40 | + | } | |
| 41 | + | if len(seen) != 3 || out.session == "" || out.tab == "" || out.pane == "" { | |
| 42 | + | return vevEnv{}, false | |
| 43 | + | } | |
| 44 | + | return out, true | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func unescapeVEVComponent(value string) (string, bool) { | |
| 48 | + | var out strings.Builder | |
| 49 | + | for i := 0; i < len(value); i++ { | |
| 50 | + | if value[i] != '%' { | |
| 51 | + | if !isUnescapedVEVByte(value[i]) { | |
| 52 | + | return "", false | |
| 53 | + | } | |
| 54 | + | out.WriteByte(value[i]) | |
| 55 | + | continue | |
| 56 | + | } | |
| 57 | + | if i+2 >= len(value) { | |
| 58 | + | return "", false | |
| 59 | + | } | |
| 60 | + | hi, highOK := upperHexValue(value[i+1]) | |
| 61 | + | lo, lowOK := upperHexValue(value[i+2]) | |
| 62 | + | if !highOK || !lowOK { | |
| 63 | + | return "", false | |
| 64 | + | } | |
| 65 | + | out.WriteByte(hi<<4 | lo) | |
| 66 | + | i += 2 | |
| 67 | + | } | |
| 68 | + | return out.String(), true | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func isUnescapedVEVByte(value byte) bool { | |
| 72 | + | return value >= 'A' && value <= 'Z' || | |
| 73 | + | value >= 'a' && value <= 'z' || | |
| 74 | + | value >= '0' && value <= '9' || | |
| 75 | + | value == '.' || value == '_' || value == '-' | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func upperHexValue(value byte) (byte, bool) { | |
| 79 | + | switch { | |
| 80 | + | case value >= '0' && value <= '9': | |
| 81 | + | return value - '0', true | |
| 82 | + | case value >= 'A' && value <= 'F': | |
| 83 | + | return value - 'A' + 10, true | |
| 84 | + | default: | |
| 85 | + | return 0, false | |
| 86 | + | } | |
| 87 | + | } | |
| @@ -0,0 +1,87 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import "strings" | |||
| 4 | + | ||||
| 5 | + | type vevEnv struct { | |||
| 6 | + | session string | |||
| 7 | + | tab string | |||
| 8 | + | pane string | |||
| 9 | + | } | |||
| 10 | + | ||||
| 11 | + | // parseVEVEnv strictly parses the identity injected into pane PTYs. All three | |||
| 12 | + | // unique fields are required and components use the daemon's uppercase %XX | |||
| 13 | + | // escaping. | |||
| 14 | + | func parseVEVEnv(value string) (vevEnv, bool) { | |||
| 15 | + | if value == "" { | |||
| 16 | + | return vevEnv{}, false | |||
| 17 | + | } | |||
| 18 | + | var out vevEnv | |||
| 19 | + | seen := make(map[string]bool, 3) | |||
| 20 | + | for part := range strings.SplitSeq(value, ",") { | |||
| 21 | + | key, encoded, found := strings.Cut(part, "=") | |||
| 22 | + | if !found || seen[key] { | |||
| 23 | + | return vevEnv{}, false | |||
| 24 | + | } | |||
| 25 | + | decoded, ok := unescapeVEVComponent(encoded) | |||
| 26 | + | if !ok || decoded == "" { | |||
| 27 | + | return vevEnv{}, false | |||
| 28 | + | } | |||
| 29 | + | switch key { | |||
| 30 | + | case "session": | |||
| 31 | + | out.session = decoded | |||
| 32 | + | case "tab": | |||
| 33 | + | out.tab = decoded | |||
| 34 | + | case "pane": | |||
| 35 | + | out.pane = decoded | |||
| 36 | + | default: | |||
| 37 | + | return vevEnv{}, false | |||
| 38 | + | } | |||
| 39 | + | seen[key] = true | |||
| 40 | + | } | |||
| 41 | + | if len(seen) != 3 || out.session == "" || out.tab == "" || out.pane == "" { | |||
| 42 | + | return vevEnv{}, false | |||
| 43 | + | } | |||
| 44 | + | return out, true | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func unescapeVEVComponent(value string) (string, bool) { | |||
| 48 | + | var out strings.Builder | |||
| 49 | + | for i := 0; i < len(value); i++ { | |||
| 50 | + | if value[i] != '%' { | |||
| 51 | + | if !isUnescapedVEVByte(value[i]) { | |||
| 52 | + | return "", false | |||
| 53 | + | } | |||
| 54 | + | out.WriteByte(value[i]) | |||
| 55 | + | continue | |||
| 56 | + | } | |||
| 57 | + | if i+2 >= len(value) { | |||
| 58 | + | return "", false | |||
| 59 | + | } | |||
| 60 | + | hi, highOK := upperHexValue(value[i+1]) | |||
| 61 | + | lo, lowOK := upperHexValue(value[i+2]) | |||
| 62 | + | if !highOK || !lowOK { | |||
| 63 | + | return "", false | |||
| 64 | + | } | |||
| 65 | + | out.WriteByte(hi<<4 | lo) | |||
| 66 | + | i += 2 | |||
| 67 | + | } | |||
| 68 | + | return out.String(), true | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func isUnescapedVEVByte(value byte) bool { | |||
| 72 | + | return value >= 'A' && value <= 'Z' || | |||
| 73 | + | value >= 'a' && value <= 'z' || | |||
| 74 | + | value >= '0' && value <= '9' || | |||
| 75 | + | value == '.' || value == '_' || value == '-' | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func upperHexValue(value byte) (byte, bool) { | |||
| 79 | + | switch { | |||
| 80 | + | case value >= '0' && value <= '9': | |||
| 81 | + | return value - '0', true | |||
| 82 | + | case value >= 'A' && value <= 'F': | |||
| 83 | + | return value - 'A' + 10, true | |||
| 84 | + | default: | |||
| 85 | + | return 0, false | |||
| 86 | + | } | |||
| 87 | + | } | |||
A internal/app/vevenv_test.go +38 -0
| @@ -0,0 +1,38 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestParseVEVEnv(t *testing.T) { | |
| 6 | + | tests := []struct { | |
| 7 | + | name string | |
| 8 | + | env string | |
| 9 | + | session string | |
| 10 | + | tab string | |
| 11 | + | pane string | |
| 12 | + | ok bool | |
| 13 | + | }{ | |
| 14 | + | {"normal", "session=dev,tab=t_abc,pane=p_def", "dev", "t_abc", "p_def", true}, | |
| 15 | + | {"escaped components", "session=a%20b,tab=t%2Cx,pane=p%3Dy", "a b", "t,x", "p=y", true}, | |
| 16 | + | {"empty", "", "", "", "", false}, | |
| 17 | + | {"missing pane", "session=dev,tab=t_abc", "", "", "", false}, | |
| 18 | + | {"garbage", "not-a-vev-value", "", "", "", false}, | |
| 19 | + | {"unknown key", "session=dev,tab=t_abc,pane=p_def,extra=x", "", "", "", false}, | |
| 20 | + | {"duplicate key", "session=dev,session=other,tab=t_abc,pane=p_def", "", "", "", false}, | |
| 21 | + | {"bad escape", "session=dev%2,tab=t_abc,pane=p_def", "", "", "", false}, | |
| 22 | + | {"lowercase escape is invalid", "session=dev%2fwork,tab=t_abc,pane=p_def", "", "", "", false}, | |
| 23 | + | {"unescaped byte is invalid", "session=dev work,tab=t_abc,pane=p_def", "", "", "", false}, | |
| 24 | + | {"raw UTF-8 is invalid", "session=dév,tab=t_abc,pane=p_def", "", "", "", false}, | |
| 25 | + | {"empty component", "session=dev,tab=,pane=p_def", "", "", "", false}, | |
| 26 | + | } | |
| 27 | + | for _, tt := range tests { | |
| 28 | + | t.Run(tt.name, func(t *testing.T) { | |
| 29 | + | got, ok := parseVEVEnv(tt.env) | |
| 30 | + | if ok != tt.ok { | |
| 31 | + | t.Fatalf("parseVEVEnv(%q) ok = %v, want %v", tt.env, ok, tt.ok) | |
| 32 | + | } | |
| 33 | + | if ok && (got.session != tt.session || got.tab != tt.tab || got.pane != tt.pane) { | |
| 34 | + | t.Fatalf("parseVEVEnv(%q) = %+v", tt.env, got) | |
| 35 | + | } | |
| 36 | + | }) | |
| 37 | + | } | |
| 38 | + | } | |
| @@ -0,0 +1,38 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestParseVEVEnv(t *testing.T) { | |||
| 6 | + | tests := []struct { | |||
| 7 | + | name string | |||
| 8 | + | env string | |||
| 9 | + | session string | |||
| 10 | + | tab string | |||
| 11 | + | pane string | |||
| 12 | + | ok bool | |||
| 13 | + | }{ | |||
| 14 | + | {"normal", "session=dev,tab=t_abc,pane=p_def", "dev", "t_abc", "p_def", true}, | |||
| 15 | + | {"escaped components", "session=a%20b,tab=t%2Cx,pane=p%3Dy", "a b", "t,x", "p=y", true}, | |||
| 16 | + | {"empty", "", "", "", "", false}, | |||
| 17 | + | {"missing pane", "session=dev,tab=t_abc", "", "", "", false}, | |||
| 18 | + | {"garbage", "not-a-vev-value", "", "", "", false}, | |||
| 19 | + | {"unknown key", "session=dev,tab=t_abc,pane=p_def,extra=x", "", "", "", false}, | |||
| 20 | + | {"duplicate key", "session=dev,session=other,tab=t_abc,pane=p_def", "", "", "", false}, | |||
| 21 | + | {"bad escape", "session=dev%2,tab=t_abc,pane=p_def", "", "", "", false}, | |||
| 22 | + | {"lowercase escape is invalid", "session=dev%2fwork,tab=t_abc,pane=p_def", "", "", "", false}, | |||
| 23 | + | {"unescaped byte is invalid", "session=dev work,tab=t_abc,pane=p_def", "", "", "", false}, | |||
| 24 | + | {"raw UTF-8 is invalid", "session=dév,tab=t_abc,pane=p_def", "", "", "", false}, | |||
| 25 | + | {"empty component", "session=dev,tab=,pane=p_def", "", "", "", false}, | |||
| 26 | + | } | |||
| 27 | + | for _, tt := range tests { | |||
| 28 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 29 | + | got, ok := parseVEVEnv(tt.env) | |||
| 30 | + | if ok != tt.ok { | |||
| 31 | + | t.Fatalf("parseVEVEnv(%q) ok = %v, want %v", tt.env, ok, tt.ok) | |||
| 32 | + | } | |||
| 33 | + | if ok && (got.session != tt.session || got.tab != tt.tab || got.pane != tt.pane) { | |||
| 34 | + | t.Fatalf("parseVEVEnv(%q) = %+v", tt.env, got) | |||
| 35 | + | } | |||
| 36 | + | }) | |||
| 37 | + | } | |||
| 38 | + | } | |||
M internal/domain/notice.go +2 -0
| @@ -41,6 +41,7 @@ const ( | |||
| 41 | 41 | NoticeClipboardTooLarge | |
| 42 | 42 | NoticeAutoResume | |
| 43 | 43 | NoticeConnection | |
| 44 | + | NoticeUser | |
| 44 | 45 | ||
| 45 | 46 | // noticeCodeLimit is an append-only declaration sentinel, not a persisted | |
| 46 | 47 | // valid notice code. Keep it last when adding a NoticeCode. | |
| @@ -68,6 +69,7 @@ var noticeSlugs = map[NoticeCode]string{ | |||
| 68 | 69 | NoticeClipboardTooLarge: "clipboard-too-large", | |
| 69 | 70 | NoticeAutoResume: "auto-resume", | |
| 70 | 71 | NoticeConnection: "connection", | |
| 72 | + | NoticeUser: "user", | |
| 71 | 73 | } | |
| 72 | 74 | ||
| 73 | 75 | func (c NoticeCode) String() string { | |
| @@ -41,6 +41,7 @@ const ( | |||||
| 41 | NoticeClipboardTooLarge | 41 | NoticeClipboardTooLarge | ||
| 42 | NoticeAutoResume | 42 | NoticeAutoResume | ||
| 43 | NoticeConnection | 43 | NoticeConnection | ||
| 44 | + | NoticeUser | |||
| 44 | 45 | ||||
| 45 | // noticeCodeLimit is an append-only declaration sentinel, not a persisted | 46 | // noticeCodeLimit is an append-only declaration sentinel, not a persisted | ||
| 46 | // valid notice code. Keep it last when adding a NoticeCode. | 47 | // valid notice code. Keep it last when adding a NoticeCode. | ||
| @@ -68,6 +69,7 @@ var noticeSlugs = map[NoticeCode]string{ | |||||
| 68 | NoticeClipboardTooLarge: "clipboard-too-large", | 69 | NoticeClipboardTooLarge: "clipboard-too-large", | ||
| 69 | NoticeAutoResume: "auto-resume", | 70 | NoticeAutoResume: "auto-resume", | ||
| 70 | NoticeConnection: "connection", | 71 | NoticeConnection: "connection", | ||
| 72 | + | NoticeUser: "user", | |||
| 71 | } | 73 | } | ||
| 72 | 74 | ||||
| 73 | func (c NoticeCode) String() string { | 75 | func (c NoticeCode) String() string { | ||
M internal/ports/frame.go +9 -7
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | |
| 4 | - | const ProtocolVersion uint16 = 17 | |
| 4 | + | const ProtocolVersion uint16 = 18 | |
| 5 | 5 | ||
| 6 | 6 | // MsgType identifies the kind of payload carried by a Frame. | |
| 7 | 7 | type MsgType uint8 | |
| @@ -21,13 +21,15 @@ const ( | |||
| 21 | 21 | MsgAck MsgType = 9 | |
| 22 | 22 | MsgImagePush MsgType = 10 | |
| 23 | 23 | MsgClientNotice MsgType = 11 | |
| 24 | + | MsgCommand MsgType = 12 | |
| 24 | 25 | ||
| 25 | - | MsgWelcome MsgType = 16 | |
| 26 | - | MsgError MsgType = 17 | |
| 27 | - | MsgOutput MsgType = 18 | |
| 28 | - | MsgDetached MsgType = 19 | |
| 29 | - | MsgPong MsgType = 20 | |
| 30 | - | MsgSessions MsgType = 21 | |
| 26 | + | MsgWelcome MsgType = 16 | |
| 27 | + | MsgError MsgType = 17 | |
| 28 | + | MsgOutput MsgType = 18 | |
| 29 | + | MsgDetached MsgType = 19 | |
| 30 | + | MsgPong MsgType = 20 | |
| 31 | + | MsgSessions MsgType = 21 | |
| 32 | + | MsgCommandResult MsgType = 22 | |
| 31 | 33 | ) | |
| 32 | 34 | ||
| 33 | 35 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | package ports | 1 | package ports | ||
| 2 | 2 | ||||
| 3 | // ProtocolVersion is the current vev IPC wire protocol version. | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | ||
| 4 | - | const ProtocolVersion uint16 = 17 | 4 | + | const ProtocolVersion uint16 = 18 |
| 5 | 5 | ||||
| 6 | // MsgType identifies the kind of payload carried by a Frame. | 6 | // MsgType identifies the kind of payload carried by a Frame. | ||
| 7 | type MsgType uint8 | 7 | type MsgType uint8 | ||
| @@ -21,13 +21,15 @@ const ( | |||||
| 21 | MsgAck MsgType = 9 | 21 | MsgAck MsgType = 9 | ||
| 22 | MsgImagePush MsgType = 10 | 22 | MsgImagePush MsgType = 10 | ||
| 23 | MsgClientNotice MsgType = 11 | 23 | MsgClientNotice MsgType = 11 | ||
| 24 | + | MsgCommand MsgType = 12 | |||
| 24 | 25 | ||||
| 25 | - | MsgWelcome MsgType = 16 | 26 | + | MsgWelcome MsgType = 16 |
| 26 | - | MsgError MsgType = 17 | 27 | + | MsgError MsgType = 17 |
| 27 | - | MsgOutput MsgType = 18 | 28 | + | MsgOutput MsgType = 18 |
| 28 | - | MsgDetached MsgType = 19 | 29 | + | MsgDetached MsgType = 19 |
| 29 | - | MsgPong MsgType = 20 | 30 | + | MsgPong MsgType = 20 |
| 30 | - | MsgSessions MsgType = 21 | 31 | + | MsgSessions MsgType = 21 |
| 32 | + | MsgCommandResult MsgType = 22 | |||
| 31 | ) | 33 | ) | ||
| 32 | 34 | ||||
| 33 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | 35 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | ||
M internal/ports/frame_test.go +13 -2
| @@ -3,8 +3,17 @@ package ports | |||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | 5 | func TestProtocolVersion(t *testing.T) { | |
| 6 | - | if ProtocolVersion != 17 { | |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 17", ProtocolVersion) | |
| 6 | + | if ProtocolVersion != 18 { | |
| 7 | + | t.Fatalf("ProtocolVersion = %d, want 18", ProtocolVersion) | |
| 8 | + | } | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | func TestControlMsgTypes(t *testing.T) { | |
| 12 | + | if MsgCommand != 12 { | |
| 13 | + | t.Fatalf("MsgCommand = %d, want 12", MsgCommand) | |
| 14 | + | } | |
| 15 | + | if MsgCommandResult != 22 { | |
| 16 | + | t.Fatalf("MsgCommandResult = %d, want 22", MsgCommandResult) | |
| 8 | 17 | } | |
| 9 | 18 | } | |
| 10 | 19 | ||
| @@ -24,12 +33,14 @@ func TestMsgTypeUnique(t *testing.T) { | |||
| 24 | 33 | {"MsgAck", MsgAck}, | |
| 25 | 34 | {"MsgImagePush", MsgImagePush}, | |
| 26 | 35 | {"MsgClientNotice", MsgClientNotice}, | |
| 36 | + | {"MsgCommand", MsgCommand}, | |
| 27 | 37 | {"MsgWelcome", MsgWelcome}, | |
| 28 | 38 | {"MsgError", MsgError}, | |
| 29 | 39 | {"MsgOutput", MsgOutput}, | |
| 30 | 40 | {"MsgDetached", MsgDetached}, | |
| 31 | 41 | {"MsgPong", MsgPong}, | |
| 32 | 42 | {"MsgSessions", MsgSessions}, | |
| 43 | + | {"MsgCommandResult", MsgCommandResult}, | |
| 33 | 44 | } | |
| 34 | 45 | ||
| 35 | 46 | seen := make(map[MsgType]string, len(tests)) | |
| @@ -3,8 +3,17 @@ package ports | |||||
| 3 | import "testing" | 3 | import "testing" | ||
| 4 | 4 | ||||
| 5 | func TestProtocolVersion(t *testing.T) { | 5 | func TestProtocolVersion(t *testing.T) { | ||
| 6 | - | if ProtocolVersion != 17 { | 6 | + | if ProtocolVersion != 18 { |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 17", ProtocolVersion) | 7 | + | t.Fatalf("ProtocolVersion = %d, want 18", ProtocolVersion) |
| 8 | + | } | |||
| 9 | + | } | |||
| 10 | + | ||||
| 11 | + | func TestControlMsgTypes(t *testing.T) { | |||
| 12 | + | if MsgCommand != 12 { | |||
| 13 | + | t.Fatalf("MsgCommand = %d, want 12", MsgCommand) | |||
| 14 | + | } | |||
| 15 | + | if MsgCommandResult != 22 { | |||
| 16 | + | t.Fatalf("MsgCommandResult = %d, want 22", MsgCommandResult) | |||
| 8 | } | 17 | } | ||
| 9 | } | 18 | } | ||
| 10 | 19 | ||||
| @@ -24,12 +33,14 @@ func TestMsgTypeUnique(t *testing.T) { | |||||
| 24 | {"MsgAck", MsgAck}, | 33 | {"MsgAck", MsgAck}, | ||
| 25 | {"MsgImagePush", MsgImagePush}, | 34 | {"MsgImagePush", MsgImagePush}, | ||
| 26 | {"MsgClientNotice", MsgClientNotice}, | 35 | {"MsgClientNotice", MsgClientNotice}, | ||
| 36 | + | {"MsgCommand", MsgCommand}, | |||
| 27 | {"MsgWelcome", MsgWelcome}, | 37 | {"MsgWelcome", MsgWelcome}, | ||
| 28 | {"MsgError", MsgError}, | 38 | {"MsgError", MsgError}, | ||
| 29 | {"MsgOutput", MsgOutput}, | 39 | {"MsgOutput", MsgOutput}, | ||
| 30 | {"MsgDetached", MsgDetached}, | 40 | {"MsgDetached", MsgDetached}, | ||
| 31 | {"MsgPong", MsgPong}, | 41 | {"MsgPong", MsgPong}, | ||
| 32 | {"MsgSessions", MsgSessions}, | 42 | {"MsgSessions", MsgSessions}, | ||
| 43 | + | {"MsgCommandResult", MsgCommandResult}, | |||
| 33 | } | 44 | } | ||
| 34 | 45 | ||||
| 35 | seen := make(map[MsgType]string, len(tests)) | 46 | seen := make(map[MsgType]string, len(tests)) | ||
M internal/ports/mocks/shell_mock.go +8 -8
| @@ -39,22 +39,22 @@ func (_m *MockShellCommandRunner) EXPECT() *MockShellCommandRunner_Expecter { | |||
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | 41 | // Run provides a mock function for the type MockShellCommandRunner | |
| 42 | - | func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error) { | |
| 42 | + | func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) (ports.ShellCommandResult, error) { | |
| 43 | 43 | ret := _mock.Called(ctx, spec) | |
| 44 | 44 | ||
| 45 | 45 | if len(ret) == 0 { | |
| 46 | 46 | panic("no return value specified for Run") | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | - | var r0 ports.CommandResult | |
| 49 | + | var r0 ports.ShellCommandResult | |
| 50 | 50 | var r1 error | |
| 51 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) (ports.CommandResult, error)); ok { | |
| 51 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) (ports.ShellCommandResult, error)); ok { | |
| 52 | 52 | return returnFunc(ctx, spec) | |
| 53 | 53 | } | |
| 54 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ports.CommandResult); ok { | |
| 54 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ports.ShellCommandResult); ok { | |
| 55 | 55 | r0 = returnFunc(ctx, spec) | |
| 56 | 56 | } else { | |
| 57 | - | r0 = ret.Get(0).(ports.CommandResult) | |
| 57 | + | r0 = ret.Get(0).(ports.ShellCommandResult) | |
| 58 | 58 | } | |
| 59 | 59 | if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok { | |
| 60 | 60 | r1 = returnFunc(ctx, spec) | |
| @@ -94,12 +94,12 @@ func (_c *MockShellCommandRunner_Run_Call) Run(run func(ctx context.Context, spe | |||
| 94 | 94 | return _c | |
| 95 | 95 | } | |
| 96 | 96 | ||
| 97 | - | func (_c *MockShellCommandRunner_Run_Call) Return(commandResult ports.CommandResult, err error) *MockShellCommandRunner_Run_Call { | |
| 98 | - | _c.Call.Return(commandResult, err) | |
| 97 | + | func (_c *MockShellCommandRunner_Run_Call) Return(shellCommandResult ports.ShellCommandResult, err error) *MockShellCommandRunner_Run_Call { | |
| 98 | + | _c.Call.Return(shellCommandResult, err) | |
| 99 | 99 | return _c | |
| 100 | 100 | } | |
| 101 | 101 | ||
| 102 | - | func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error)) *MockShellCommandRunner_Run_Call { | |
| 102 | + | func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) (ports.ShellCommandResult, error)) *MockShellCommandRunner_Run_Call { | |
| 103 | 103 | _c.Call.Return(run) | |
| 104 | 104 | return _c | |
| 105 | 105 | } | |
| @@ -39,22 +39,22 @@ func (_m *MockShellCommandRunner) EXPECT() *MockShellCommandRunner_Expecter { | |||||
| 39 | } | 39 | } | ||
| 40 | 40 | ||||
| 41 | // Run provides a mock function for the type MockShellCommandRunner | 41 | // Run provides a mock function for the type MockShellCommandRunner | ||
| 42 | - | func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error) { | 42 | + | func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) (ports.ShellCommandResult, error) { |
| 43 | ret := _mock.Called(ctx, spec) | 43 | ret := _mock.Called(ctx, spec) | ||
| 44 | 44 | ||||
| 45 | if len(ret) == 0 { | 45 | if len(ret) == 0 { | ||
| 46 | panic("no return value specified for Run") | 46 | panic("no return value specified for Run") | ||
| 47 | } | 47 | } | ||
| 48 | 48 | ||||
| 49 | - | var r0 ports.CommandResult | 49 | + | var r0 ports.ShellCommandResult |
| 50 | var r1 error | 50 | var r1 error | ||
| 51 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) (ports.CommandResult, error)); ok { | 51 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) (ports.ShellCommandResult, error)); ok { |
| 52 | return returnFunc(ctx, spec) | 52 | return returnFunc(ctx, spec) | ||
| 53 | } | 53 | } | ||
| 54 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ports.CommandResult); ok { | 54 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ports.ShellCommandResult); ok { |
| 55 | r0 = returnFunc(ctx, spec) | 55 | r0 = returnFunc(ctx, spec) | ||
| 56 | } else { | 56 | } else { | ||
| 57 | - | r0 = ret.Get(0).(ports.CommandResult) | 57 | + | r0 = ret.Get(0).(ports.ShellCommandResult) |
| 58 | } | 58 | } | ||
| 59 | if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok { | 59 | if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok { | ||
| 60 | r1 = returnFunc(ctx, spec) | 60 | r1 = returnFunc(ctx, spec) | ||
| @@ -94,12 +94,12 @@ func (_c *MockShellCommandRunner_Run_Call) Run(run func(ctx context.Context, spe | |||||
| 94 | return _c | 94 | return _c | ||
| 95 | } | 95 | } | ||
| 96 | 96 | ||||
| 97 | - | func (_c *MockShellCommandRunner_Run_Call) Return(commandResult ports.CommandResult, err error) *MockShellCommandRunner_Run_Call { | 97 | + | func (_c *MockShellCommandRunner_Run_Call) Return(shellCommandResult ports.ShellCommandResult, err error) *MockShellCommandRunner_Run_Call { |
| 98 | - | _c.Call.Return(commandResult, err) | 98 | + | _c.Call.Return(shellCommandResult, err) |
| 99 | return _c | 99 | return _c | ||
| 100 | } | 100 | } | ||
| 101 | 101 | ||||
| 102 | - | func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) (ports.CommandResult, error)) *MockShellCommandRunner_Run_Call { | 102 | + | func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) (ports.ShellCommandResult, error)) *MockShellCommandRunner_Run_Call { |
| 103 | _c.Call.Return(run) | 103 | _c.Call.Return(run) | ||
| 104 | return _c | 104 | return _c | ||
| 105 | } | 105 | } | ||
M internal/ports/shell.go +6 -6
| @@ -14,11 +14,11 @@ type CommandSpec struct { | |||
| 14 | 14 | StderrLimit int | |
| 15 | 15 | } | |
| 16 | 16 | ||
| 17 | - | // CommandResult carries bounded output and the process exit status. ExitCode is | |
| 18 | - | // 0 when the command ran to completion successfully, the process exit code when | |
| 19 | - | // it ran and failed, and -1 when the command could not be started or was | |
| 20 | - | // terminated by a signal. | |
| 21 | - | type CommandResult struct { | |
| 17 | + | // ShellCommandResult carries bounded output and the process exit status. | |
| 18 | + | // ExitCode is 0 when the command ran to completion successfully, the process | |
| 19 | + | // exit code when it ran and failed, and -1 when the command could not be | |
| 20 | + | // started or was terminated by a signal. | |
| 21 | + | type ShellCommandResult struct { | |
| 22 | 22 | Stdout []byte | |
| 23 | 23 | Stderr []byte | |
| 24 | 24 | ExitCode int | |
| @@ -26,5 +26,5 @@ type CommandResult struct { | |||
| 26 | 26 | ||
| 27 | 27 | // ShellCommandRunner runs shell commands and returns bounded output. | |
| 28 | 28 | type ShellCommandRunner interface { | |
| 29 | - | Run(ctx context.Context, spec CommandSpec) (CommandResult, error) | |
| 29 | + | Run(ctx context.Context, spec CommandSpec) (ShellCommandResult, error) | |
| 30 | 30 | } | |
| @@ -14,11 +14,11 @@ type CommandSpec struct { | |||||
| 14 | StderrLimit int | 14 | StderrLimit int | ||
| 15 | } | 15 | } | ||
| 16 | 16 | ||||
| 17 | - | // CommandResult carries bounded output and the process exit status. ExitCode is | 17 | + | // ShellCommandResult carries bounded output and the process exit status. |
| 18 | - | // 0 when the command ran to completion successfully, the process exit code when | 18 | + | // ExitCode is 0 when the command ran to completion successfully, the process |
| 19 | - | // it ran and failed, and -1 when the command could not be started or was | 19 | + | // exit code when it ran and failed, and -1 when the command could not be |
| 20 | - | // terminated by a signal. | 20 | + | // started or was terminated by a signal. |
| 21 | - | type CommandResult struct { | 21 | + | type ShellCommandResult struct { |
| 22 | Stdout []byte | 22 | Stdout []byte | ||
| 23 | Stderr []byte | 23 | Stderr []byte | ||
| 24 | ExitCode int | 24 | ExitCode int | ||
| @@ -26,5 +26,5 @@ type CommandResult struct { | |||||
| 26 | 26 | ||||
| 27 | // ShellCommandRunner runs shell commands and returns bounded output. | 27 | // ShellCommandRunner runs shell commands and returns bounded output. | ||
| 28 | type ShellCommandRunner interface { | 28 | type ShellCommandRunner interface { | ||
| 29 | - | Run(ctx context.Context, spec CommandSpec) (CommandResult, error) | 29 | + | Run(ctx context.Context, spec CommandSpec) (ShellCommandResult, error) |
| 30 | } | 30 | } | ||
M internal/ports/wire.go +5 -0
| @@ -45,6 +45,11 @@ const ( | |||
| 45 | 45 | ErrNameTaken uint16 = 3 | |
| 46 | 46 | ErrServerShutdown uint16 = 4 | |
| 47 | 47 | ErrInvalidSessionName uint16 = 5 | |
| 48 | + | ErrUnknownCommand uint16 = 6 | |
| 49 | + | ErrNotScriptable uint16 = 7 | |
| 50 | + | ErrInvalidCommandArgs uint16 = 8 | |
| 51 | + | ErrNoSuchTarget uint16 = 9 | |
| 52 | + | ErrAmbiguousTarget uint16 = 10 | |
| 48 | 53 | ErrInternal uint16 = 255 | |
| 49 | 54 | ) | |
| 50 | 55 | ||
| @@ -45,6 +45,11 @@ const ( | |||||
| 45 | ErrNameTaken uint16 = 3 | 45 | ErrNameTaken uint16 = 3 | ||
| 46 | ErrServerShutdown uint16 = 4 | 46 | ErrServerShutdown uint16 = 4 | ||
| 47 | ErrInvalidSessionName uint16 = 5 | 47 | ErrInvalidSessionName uint16 = 5 | ||
| 48 | + | ErrUnknownCommand uint16 = 6 | |||
| 49 | + | ErrNotScriptable uint16 = 7 | |||
| 50 | + | ErrInvalidCommandArgs uint16 = 8 | |||
| 51 | + | ErrNoSuchTarget uint16 = 9 | |||
| 52 | + | ErrAmbiguousTarget uint16 = 10 | |||
| 48 | ErrInternal uint16 = 255 | 53 | ErrInternal uint16 = 255 | ||
| 49 | ) | 54 | ) | ||
| 50 | 55 | ||||
A internal/ports/wire_command.go +165 -0
| @@ -0,0 +1,165 @@ | |||
| 1 | + | package ports | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/binary" | |
| 5 | + | "errors" | |
| 6 | + | "math" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // ErrTooManyCommandArgs reports that a request exceeds the wire count limit. | |
| 10 | + | var ErrTooManyCommandArgs = errors.New("ports: too many command arguments") | |
| 11 | + | ||
| 12 | + | // CommandRequest asks the daemon to run one control command. Version must | |
| 13 | + | // stay first so a future payload layout can still be rejected cleanly. | |
| 14 | + | type CommandRequest struct { | |
| 15 | + | Version uint16 | |
| 16 | + | Self bool | |
| 17 | + | Slug string | |
| 18 | + | Args []string | |
| 19 | + | TargetSession string | |
| 20 | + | TargetTab string | |
| 21 | + | TargetPane string | |
| 22 | + | JSON bool | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // CommandResult reports a control command's outcome. | |
| 26 | + | type CommandResult struct { | |
| 27 | + | OK bool | |
| 28 | + | Code uint16 | |
| 29 | + | Text string | |
| 30 | + | Output string | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // PeekCommandVersion returns the leading protocol version from a | |
| 34 | + | // CommandRequest payload. | |
| 35 | + | func PeekCommandVersion(b []byte) (uint16, bool) { | |
| 36 | + | if len(b) < 2 { | |
| 37 | + | return 0, false | |
| 38 | + | } | |
| 39 | + | return binary.BigEndian.Uint16(b), true | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // MarshalCommandRequest encodes m into a CommandRequest payload. | |
| 43 | + | func MarshalCommandRequest(m CommandRequest) ([]byte, error) { | |
| 44 | + | if len(m.Args) > math.MaxUint16 { | |
| 45 | + | return nil, ErrTooManyCommandArgs | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | w := payloadWriter{} | |
| 49 | + | w.putUint16(m.Version) | |
| 50 | + | if m.Self { | |
| 51 | + | w.putUint8(1) | |
| 52 | + | } else { | |
| 53 | + | w.putUint8(0) | |
| 54 | + | } | |
| 55 | + | w.putString(m.Slug) | |
| 56 | + | w.putUint16(uint16(len(m.Args))) | |
| 57 | + | for _, arg := range m.Args { | |
| 58 | + | w.putLongString(arg) | |
| 59 | + | } | |
| 60 | + | w.putString(m.TargetSession) | |
| 61 | + | w.putString(m.TargetTab) | |
| 62 | + | w.putString(m.TargetPane) | |
| 63 | + | if m.JSON { | |
| 64 | + | w.putUint8(1) | |
| 65 | + | } else { | |
| 66 | + | w.putUint8(0) | |
| 67 | + | } | |
| 68 | + | return w.b, nil | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // UnmarshalCommandRequest decodes a strict CommandRequest payload. | |
| 72 | + | func UnmarshalCommandRequest(b []byte) (CommandRequest, error) { | |
| 73 | + | r := payloadReader{b: b} | |
| 74 | + | var m CommandRequest | |
| 75 | + | var err error | |
| 76 | + | ||
| 77 | + | if m.Version, err = r.getUint16(); err != nil { | |
| 78 | + | return CommandRequest{}, err | |
| 79 | + | } | |
| 80 | + | selfFlag, err := r.getUint8() | |
| 81 | + | if err != nil { | |
| 82 | + | return CommandRequest{}, err | |
| 83 | + | } | |
| 84 | + | m.Self = selfFlag != 0 | |
| 85 | + | if m.Slug, err = r.getString(); err != nil { | |
| 86 | + | return CommandRequest{}, err | |
| 87 | + | } | |
| 88 | + | argCount, err := r.getUint16() | |
| 89 | + | if err != nil { | |
| 90 | + | return CommandRequest{}, err | |
| 91 | + | } | |
| 92 | + | // Every argument requires at least a uint32 length prefix. Reject an | |
| 93 | + | // impossible count before allocating from untrusted input. | |
| 94 | + | if uint64(argCount) > uint64(len(r.b)/4) { | |
| 95 | + | return CommandRequest{}, errShortPayload | |
| 96 | + | } | |
| 97 | + | if argCount != 0 { | |
| 98 | + | m.Args = make([]string, 0, int(argCount)) | |
| 99 | + | for range int(argCount) { | |
| 100 | + | arg, err := r.getLongString() | |
| 101 | + | if err != nil { | |
| 102 | + | return CommandRequest{}, err | |
| 103 | + | } | |
| 104 | + | m.Args = append(m.Args, arg) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | if m.TargetSession, err = r.getString(); err != nil { | |
| 108 | + | return CommandRequest{}, err | |
| 109 | + | } | |
| 110 | + | if m.TargetTab, err = r.getString(); err != nil { | |
| 111 | + | return CommandRequest{}, err | |
| 112 | + | } | |
| 113 | + | if m.TargetPane, err = r.getString(); err != nil { | |
| 114 | + | return CommandRequest{}, err | |
| 115 | + | } | |
| 116 | + | jsonFlag, err := r.getUint8() | |
| 117 | + | if err != nil { | |
| 118 | + | return CommandRequest{}, err | |
| 119 | + | } | |
| 120 | + | m.JSON = jsonFlag != 0 | |
| 121 | + | if err := r.done(); err != nil { | |
| 122 | + | return CommandRequest{}, err | |
| 123 | + | } | |
| 124 | + | return m, nil | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // MarshalCommandResult encodes m into a CommandResult payload. | |
| 128 | + | func MarshalCommandResult(m CommandResult) []byte { | |
| 129 | + | w := payloadWriter{} | |
| 130 | + | if m.OK { | |
| 131 | + | w.putUint8(1) | |
| 132 | + | } else { | |
| 133 | + | w.putUint8(0) | |
| 134 | + | } | |
| 135 | + | w.putUint16(m.Code) | |
| 136 | + | w.putString(m.Text) | |
| 137 | + | w.putLongString(m.Output) | |
| 138 | + | return w.b | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // UnmarshalCommandResult decodes a strict CommandResult payload. | |
| 142 | + | func UnmarshalCommandResult(b []byte) (CommandResult, error) { | |
| 143 | + | r := payloadReader{b: b} | |
| 144 | + | var m CommandResult | |
| 145 | + | var err error | |
| 146 | + | ||
| 147 | + | okFlag, err := r.getUint8() | |
| 148 | + | if err != nil { | |
| 149 | + | return CommandResult{}, err | |
| 150 | + | } | |
| 151 | + | m.OK = okFlag != 0 | |
| 152 | + | if m.Code, err = r.getUint16(); err != nil { | |
| 153 | + | return CommandResult{}, err | |
| 154 | + | } | |
| 155 | + | if m.Text, err = r.getString(); err != nil { | |
| 156 | + | return CommandResult{}, err | |
| 157 | + | } | |
| 158 | + | if m.Output, err = r.getLongString(); err != nil { | |
| 159 | + | return CommandResult{}, err | |
| 160 | + | } | |
| 161 | + | if err := r.done(); err != nil { | |
| 162 | + | return CommandResult{}, err | |
| 163 | + | } | |
| 164 | + | return m, nil | |
| 165 | + | } | |
| @@ -0,0 +1,165 @@ | |||||
| 1 | + | package ports | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/binary" | |||
| 5 | + | "errors" | |||
| 6 | + | "math" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // ErrTooManyCommandArgs reports that a request exceeds the wire count limit. | |||
| 10 | + | var ErrTooManyCommandArgs = errors.New("ports: too many command arguments") | |||
| 11 | + | ||||
| 12 | + | // CommandRequest asks the daemon to run one control command. Version must | |||
| 13 | + | // stay first so a future payload layout can still be rejected cleanly. | |||
| 14 | + | type CommandRequest struct { | |||
| 15 | + | Version uint16 | |||
| 16 | + | Self bool | |||
| 17 | + | Slug string | |||
| 18 | + | Args []string | |||
| 19 | + | TargetSession string | |||
| 20 | + | TargetTab string | |||
| 21 | + | TargetPane string | |||
| 22 | + | JSON bool | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // CommandResult reports a control command's outcome. | |||
| 26 | + | type CommandResult struct { | |||
| 27 | + | OK bool | |||
| 28 | + | Code uint16 | |||
| 29 | + | Text string | |||
| 30 | + | Output string | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // PeekCommandVersion returns the leading protocol version from a | |||
| 34 | + | // CommandRequest payload. | |||
| 35 | + | func PeekCommandVersion(b []byte) (uint16, bool) { | |||
| 36 | + | if len(b) < 2 { | |||
| 37 | + | return 0, false | |||
| 38 | + | } | |||
| 39 | + | return binary.BigEndian.Uint16(b), true | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // MarshalCommandRequest encodes m into a CommandRequest payload. | |||
| 43 | + | func MarshalCommandRequest(m CommandRequest) ([]byte, error) { | |||
| 44 | + | if len(m.Args) > math.MaxUint16 { | |||
| 45 | + | return nil, ErrTooManyCommandArgs | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | w := payloadWriter{} | |||
| 49 | + | w.putUint16(m.Version) | |||
| 50 | + | if m.Self { | |||
| 51 | + | w.putUint8(1) | |||
| 52 | + | } else { | |||
| 53 | + | w.putUint8(0) | |||
| 54 | + | } | |||
| 55 | + | w.putString(m.Slug) | |||
| 56 | + | w.putUint16(uint16(len(m.Args))) | |||
| 57 | + | for _, arg := range m.Args { | |||
| 58 | + | w.putLongString(arg) | |||
| 59 | + | } | |||
| 60 | + | w.putString(m.TargetSession) | |||
| 61 | + | w.putString(m.TargetTab) | |||
| 62 | + | w.putString(m.TargetPane) | |||
| 63 | + | if m.JSON { | |||
| 64 | + | w.putUint8(1) | |||
| 65 | + | } else { | |||
| 66 | + | w.putUint8(0) | |||
| 67 | + | } | |||
| 68 | + | return w.b, nil | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // UnmarshalCommandRequest decodes a strict CommandRequest payload. | |||
| 72 | + | func UnmarshalCommandRequest(b []byte) (CommandRequest, error) { | |||
| 73 | + | r := payloadReader{b: b} | |||
| 74 | + | var m CommandRequest | |||
| 75 | + | var err error | |||
| 76 | + | ||||
| 77 | + | if m.Version, err = r.getUint16(); err != nil { | |||
| 78 | + | return CommandRequest{}, err | |||
| 79 | + | } | |||
| 80 | + | selfFlag, err := r.getUint8() | |||
| 81 | + | if err != nil { | |||
| 82 | + | return CommandRequest{}, err | |||
| 83 | + | } | |||
| 84 | + | m.Self = selfFlag != 0 | |||
| 85 | + | if m.Slug, err = r.getString(); err != nil { | |||
| 86 | + | return CommandRequest{}, err | |||
| 87 | + | } | |||
| 88 | + | argCount, err := r.getUint16() | |||
| 89 | + | if err != nil { | |||
| 90 | + | return CommandRequest{}, err | |||
| 91 | + | } | |||
| 92 | + | // Every argument requires at least a uint32 length prefix. Reject an | |||
| 93 | + | // impossible count before allocating from untrusted input. | |||
| 94 | + | if uint64(argCount) > uint64(len(r.b)/4) { | |||
| 95 | + | return CommandRequest{}, errShortPayload | |||
| 96 | + | } | |||
| 97 | + | if argCount != 0 { | |||
| 98 | + | m.Args = make([]string, 0, int(argCount)) | |||
| 99 | + | for range int(argCount) { | |||
| 100 | + | arg, err := r.getLongString() | |||
| 101 | + | if err != nil { | |||
| 102 | + | return CommandRequest{}, err | |||
| 103 | + | } | |||
| 104 | + | m.Args = append(m.Args, arg) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | if m.TargetSession, err = r.getString(); err != nil { | |||
| 108 | + | return CommandRequest{}, err | |||
| 109 | + | } | |||
| 110 | + | if m.TargetTab, err = r.getString(); err != nil { | |||
| 111 | + | return CommandRequest{}, err | |||
| 112 | + | } | |||
| 113 | + | if m.TargetPane, err = r.getString(); err != nil { | |||
| 114 | + | return CommandRequest{}, err | |||
| 115 | + | } | |||
| 116 | + | jsonFlag, err := r.getUint8() | |||
| 117 | + | if err != nil { | |||
| 118 | + | return CommandRequest{}, err | |||
| 119 | + | } | |||
| 120 | + | m.JSON = jsonFlag != 0 | |||
| 121 | + | if err := r.done(); err != nil { | |||
| 122 | + | return CommandRequest{}, err | |||
| 123 | + | } | |||
| 124 | + | return m, nil | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // MarshalCommandResult encodes m into a CommandResult payload. | |||
| 128 | + | func MarshalCommandResult(m CommandResult) []byte { | |||
| 129 | + | w := payloadWriter{} | |||
| 130 | + | if m.OK { | |||
| 131 | + | w.putUint8(1) | |||
| 132 | + | } else { | |||
| 133 | + | w.putUint8(0) | |||
| 134 | + | } | |||
| 135 | + | w.putUint16(m.Code) | |||
| 136 | + | w.putString(m.Text) | |||
| 137 | + | w.putLongString(m.Output) | |||
| 138 | + | return w.b | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // UnmarshalCommandResult decodes a strict CommandResult payload. | |||
| 142 | + | func UnmarshalCommandResult(b []byte) (CommandResult, error) { | |||
| 143 | + | r := payloadReader{b: b} | |||
| 144 | + | var m CommandResult | |||
| 145 | + | var err error | |||
| 146 | + | ||||
| 147 | + | okFlag, err := r.getUint8() | |||
| 148 | + | if err != nil { | |||
| 149 | + | return CommandResult{}, err | |||
| 150 | + | } | |||
| 151 | + | m.OK = okFlag != 0 | |||
| 152 | + | if m.Code, err = r.getUint16(); err != nil { | |||
| 153 | + | return CommandResult{}, err | |||
| 154 | + | } | |||
| 155 | + | if m.Text, err = r.getString(); err != nil { | |||
| 156 | + | return CommandResult{}, err | |||
| 157 | + | } | |||
| 158 | + | if m.Output, err = r.getLongString(); err != nil { | |||
| 159 | + | return CommandResult{}, err | |||
| 160 | + | } | |||
| 161 | + | if err := r.done(); err != nil { | |||
| 162 | + | return CommandResult{}, err | |||
| 163 | + | } | |||
| 164 | + | return m, nil | |||
| 165 | + | } | |||
M internal/ports/wire_test.go +142 -2
| @@ -3,6 +3,7 @@ package ports | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "fmt" | |
| 6 | + | "math" | |
| 6 | 7 | "reflect" | |
| 7 | 8 | "testing" | |
| 8 | 9 | ||
| @@ -121,7 +122,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||
| 121 | 122 | Env: []string{"A=B", "XY=123"}, | |
| 122 | 123 | }) | |
| 123 | 124 | want := []byte{ | |
| 124 | - | 0x00, 0x11, 0x00, // version, intent | |
| 125 | + | 0x00, 0x12, 0x00, // version, intent | |
| 125 | 126 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 126 | 127 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | |
| 127 | 128 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | |
| @@ -335,7 +336,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||
| 335 | 336 | } | |
| 336 | 337 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | |
| 337 | 338 | require.Equal(t, want, MarshalTheme(cleared)) | |
| 338 | - | require.Equal(t, uint16(17), ProtocolVersion) | |
| 339 | + | require.Equal(t, uint16(18), ProtocolVersion) | |
| 339 | 340 | } | |
| 340 | 341 | ||
| 341 | 342 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | |
| @@ -442,6 +443,145 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) { | |||
| 442 | 443 | assertTrailingGarbageFails(t, full, UnmarshalWelcome) | |
| 443 | 444 | } | |
| 444 | 445 | ||
| 446 | + | func TestCommandErrorCodes(t *testing.T) { | |
| 447 | + | require.Equal(t, uint16(6), ErrUnknownCommand) | |
| 448 | + | require.Equal(t, uint16(7), ErrNotScriptable) | |
| 449 | + | require.Equal(t, uint16(8), ErrInvalidCommandArgs) | |
| 450 | + | require.Equal(t, uint16(9), ErrNoSuchTarget) | |
| 451 | + | require.Equal(t, uint16(10), ErrAmbiguousTarget) | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |
| 455 | + | tests := []struct { | |
| 456 | + | name string | |
| 457 | + | msg CommandRequest | |
| 458 | + | want []byte | |
| 459 | + | }{ | |
| 460 | + | { | |
| 461 | + | name: "minimal", | |
| 462 | + | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | |
| 463 | + | want: []byte{ | |
| 464 | + | 0x00, 0x12, | |
| 465 | + | 0x00, | |
| 466 | + | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | |
| 467 | + | 0x00, 0x00, | |
| 468 | + | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 469 | + | 0x00, | |
| 470 | + | }, | |
| 471 | + | }, | |
| 472 | + | { | |
| 473 | + | name: "full", | |
| 474 | + | msg: CommandRequest{ | |
| 475 | + | Version: ProtocolVersion, | |
| 476 | + | Self: true, | |
| 477 | + | Slug: "toast", | |
| 478 | + | Args: []string{"-l", "warn", "tests KO"}, | |
| 479 | + | TargetSession: "dev", | |
| 480 | + | TargetTab: "t_abc", | |
| 481 | + | TargetPane: "p_def", | |
| 482 | + | JSON: true, | |
| 483 | + | }, | |
| 484 | + | want: []byte{ | |
| 485 | + | 0x00, 0x12, | |
| 486 | + | 0x01, | |
| 487 | + | 0x00, 0x05, 't', 'o', 'a', 's', 't', | |
| 488 | + | 0x00, 0x03, | |
| 489 | + | 0x00, 0x00, 0x00, 0x02, '-', 'l', | |
| 490 | + | 0x00, 0x00, 0x00, 0x04, 'w', 'a', 'r', 'n', | |
| 491 | + | 0x00, 0x00, 0x00, 0x08, 't', 'e', 's', 't', 's', ' ', 'K', 'O', | |
| 492 | + | 0x00, 0x03, 'd', 'e', 'v', | |
| 493 | + | 0x00, 0x05, 't', '_', 'a', 'b', 'c', | |
| 494 | + | 0x00, 0x05, 'p', '_', 'd', 'e', 'f', | |
| 495 | + | 0x01, | |
| 496 | + | }, | |
| 497 | + | }, | |
| 498 | + | } | |
| 499 | + | ||
| 500 | + | for _, tt := range tests { | |
| 501 | + | t.Run(tt.name, func(t *testing.T) { | |
| 502 | + | got, err := MarshalCommandRequest(tt.msg) | |
| 503 | + | require.NoError(t, err) | |
| 504 | + | require.Equal(t, tt.want, got) | |
| 505 | + | back, err := UnmarshalCommandRequest(got) | |
| 506 | + | require.NoError(t, err) | |
| 507 | + | require.Equal(t, tt.msg, back) | |
| 508 | + | version, ok := PeekCommandVersion(got) | |
| 509 | + | require.True(t, ok) | |
| 510 | + | require.Equal(t, tt.msg.Version, version) | |
| 511 | + | assertAllPrefixesFail(t, got, UnmarshalCommandRequest) | |
| 512 | + | assertTrailingGarbageFails(t, got, UnmarshalCommandRequest) | |
| 513 | + | }) | |
| 514 | + | } | |
| 515 | + | ||
| 516 | + | if _, ok := PeekCommandVersion([]byte{0x00}); ok { | |
| 517 | + | t.Fatal("PeekCommandVersion accepted a one-byte payload") | |
| 518 | + | } | |
| 519 | + | } | |
| 520 | + | ||
| 521 | + | func TestMarshalCommandRequestRejectsTooManyArguments(t *testing.T) { | |
| 522 | + | payload, err := MarshalCommandRequest(CommandRequest{ | |
| 523 | + | Version: ProtocolVersion, | |
| 524 | + | Slug: "toast", | |
| 525 | + | Args: make([]string, math.MaxUint16+1), | |
| 526 | + | }) | |
| 527 | + | ||
| 528 | + | require.Nil(t, payload) | |
| 529 | + | require.ErrorIs(t, err, ErrTooManyCommandArgs) | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | func TestCommandRequestRejectsImpossibleArgumentCount(t *testing.T) { | |
| 533 | + | payload, err := MarshalCommandRequest(CommandRequest{Version: ProtocolVersion, Slug: "toast"}) | |
| 534 | + | require.NoError(t, err) | |
| 535 | + | argCountOffset := 2 + 1 + 2 + len("toast") | |
| 536 | + | payload[argCountOffset] = 0xff | |
| 537 | + | payload[argCountOffset+1] = 0xff | |
| 538 | + | ||
| 539 | + | if _, err := UnmarshalCommandRequest(payload); err == nil { | |
| 540 | + | t.Fatal("UnmarshalCommandRequest accepted an impossible argument count") | |
| 541 | + | } | |
| 542 | + | } | |
| 543 | + | ||
| 544 | + | func TestCommandResultGoldenAndRoundTrip(t *testing.T) { | |
| 545 | + | tests := []struct { | |
| 546 | + | name string | |
| 547 | + | msg CommandResult | |
| 548 | + | want []byte | |
| 549 | + | }{ | |
| 550 | + | { | |
| 551 | + | name: "error", | |
| 552 | + | msg: CommandResult{Code: ErrNoSuchTarget, Text: "no such session"}, | |
| 553 | + | want: []byte{ | |
| 554 | + | 0x00, | |
| 555 | + | 0x00, 0x09, | |
| 556 | + | 0x00, 0x0f, 'n', 'o', ' ', 's', 'u', 'c', 'h', ' ', 's', 'e', 's', 's', 'i', 'o', 'n', | |
| 557 | + | 0x00, 0x00, 0x00, 0x00, | |
| 558 | + | }, | |
| 559 | + | }, | |
| 560 | + | { | |
| 561 | + | name: "success with output", | |
| 562 | + | msg: CommandResult{OK: true, Text: "listed", Output: "ID\tFOCUSED\n"}, | |
| 563 | + | want: []byte{ | |
| 564 | + | 0x01, | |
| 565 | + | 0x00, 0x00, | |
| 566 | + | 0x00, 0x06, 'l', 'i', 's', 't', 'e', 'd', | |
| 567 | + | 0x00, 0x00, 0x00, 0x0b, 'I', 'D', '\t', 'F', 'O', 'C', 'U', 'S', 'E', 'D', '\n', | |
| 568 | + | }, | |
| 569 | + | }, | |
| 570 | + | } | |
| 571 | + | ||
| 572 | + | for _, tt := range tests { | |
| 573 | + | t.Run(tt.name, func(t *testing.T) { | |
| 574 | + | got := MarshalCommandResult(tt.msg) | |
| 575 | + | require.Equal(t, tt.want, got) | |
| 576 | + | back, err := UnmarshalCommandResult(got) | |
| 577 | + | require.NoError(t, err) | |
| 578 | + | require.Equal(t, tt.msg, back) | |
| 579 | + | assertAllPrefixesFail(t, got, UnmarshalCommandResult) | |
| 580 | + | assertTrailingGarbageFails(t, got, UnmarshalCommandResult) | |
| 581 | + | }) | |
| 582 | + | } | |
| 583 | + | } | |
| 584 | + | ||
| 445 | 585 | func TestErrorMsgGoldenAndRoundTrip(t *testing.T) { | |
| 446 | 586 | msg := ErrorMsg{Code: ErrNameTaken, Text: "name taken"} | |
| 447 | 587 | want := []byte{0x00, 0x03, 0x00, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x20, 0x74, 0x61, 0x6b, 0x65, 0x6e} | |
| @@ -3,6 +3,7 @@ package ports | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "math" | |||
| 6 | "reflect" | 7 | "reflect" | ||
| 7 | "testing" | 8 | "testing" | ||
| 8 | 9 | ||||
| @@ -121,7 +122,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||||
| 121 | Env: []string{"A=B", "XY=123"}, | 122 | Env: []string{"A=B", "XY=123"}, | ||
| 122 | }) | 123 | }) | ||
| 123 | want := []byte{ | 124 | want := []byte{ | ||
| 124 | - | 0x00, 0x11, 0x00, // version, intent | 125 | + | 0x00, 0x12, 0x00, // version, intent |
| 125 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 126 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| 126 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | 127 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | ||
| 127 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | 128 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | ||
| @@ -335,7 +336,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||||
| 335 | } | 336 | } | ||
| 336 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | 337 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | ||
| 337 | require.Equal(t, want, MarshalTheme(cleared)) | 338 | require.Equal(t, want, MarshalTheme(cleared)) | ||
| 338 | - | require.Equal(t, uint16(17), ProtocolVersion) | 339 | + | require.Equal(t, uint16(18), ProtocolVersion) |
| 339 | } | 340 | } | ||
| 340 | 341 | ||||
| 341 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | 342 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | ||
| @@ -442,6 +443,145 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) { | |||||
| 442 | assertTrailingGarbageFails(t, full, UnmarshalWelcome) | 443 | assertTrailingGarbageFails(t, full, UnmarshalWelcome) | ||
| 443 | } | 444 | } | ||
| 444 | 445 | ||||
| 446 | + | func TestCommandErrorCodes(t *testing.T) { | |||
| 447 | + | require.Equal(t, uint16(6), ErrUnknownCommand) | |||
| 448 | + | require.Equal(t, uint16(7), ErrNotScriptable) | |||
| 449 | + | require.Equal(t, uint16(8), ErrInvalidCommandArgs) | |||
| 450 | + | require.Equal(t, uint16(9), ErrNoSuchTarget) | |||
| 451 | + | require.Equal(t, uint16(10), ErrAmbiguousTarget) | |||
| 452 | + | } | |||
| 453 | + | ||||
| 454 | + | func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 455 | + | tests := []struct { | |||
| 456 | + | name string | |||
| 457 | + | msg CommandRequest | |||
| 458 | + | want []byte | |||
| 459 | + | }{ | |||
| 460 | + | { | |||
| 461 | + | name: "minimal", | |||
| 462 | + | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | |||
| 463 | + | want: []byte{ | |||
| 464 | + | 0x00, 0x12, | |||
| 465 | + | 0x00, | |||
| 466 | + | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | |||
| 467 | + | 0x00, 0x00, | |||
| 468 | + | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |||
| 469 | + | 0x00, | |||
| 470 | + | }, | |||
| 471 | + | }, | |||
| 472 | + | { | |||
| 473 | + | name: "full", | |||
| 474 | + | msg: CommandRequest{ | |||
| 475 | + | Version: ProtocolVersion, | |||
| 476 | + | Self: true, | |||
| 477 | + | Slug: "toast", | |||
| 478 | + | Args: []string{"-l", "warn", "tests KO"}, | |||
| 479 | + | TargetSession: "dev", | |||
| 480 | + | TargetTab: "t_abc", | |||
| 481 | + | TargetPane: "p_def", | |||
| 482 | + | JSON: true, | |||
| 483 | + | }, | |||
| 484 | + | want: []byte{ | |||
| 485 | + | 0x00, 0x12, | |||
| 486 | + | 0x01, | |||
| 487 | + | 0x00, 0x05, 't', 'o', 'a', 's', 't', | |||
| 488 | + | 0x00, 0x03, | |||
| 489 | + | 0x00, 0x00, 0x00, 0x02, '-', 'l', | |||
| 490 | + | 0x00, 0x00, 0x00, 0x04, 'w', 'a', 'r', 'n', | |||
| 491 | + | 0x00, 0x00, 0x00, 0x08, 't', 'e', 's', 't', 's', ' ', 'K', 'O', | |||
| 492 | + | 0x00, 0x03, 'd', 'e', 'v', | |||
| 493 | + | 0x00, 0x05, 't', '_', 'a', 'b', 'c', | |||
| 494 | + | 0x00, 0x05, 'p', '_', 'd', 'e', 'f', | |||
| 495 | + | 0x01, | |||
| 496 | + | }, | |||
| 497 | + | }, | |||
| 498 | + | } | |||
| 499 | + | ||||
| 500 | + | for _, tt := range tests { | |||
| 501 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 502 | + | got, err := MarshalCommandRequest(tt.msg) | |||
| 503 | + | require.NoError(t, err) | |||
| 504 | + | require.Equal(t, tt.want, got) | |||
| 505 | + | back, err := UnmarshalCommandRequest(got) | |||
| 506 | + | require.NoError(t, err) | |||
| 507 | + | require.Equal(t, tt.msg, back) | |||
| 508 | + | version, ok := PeekCommandVersion(got) | |||
| 509 | + | require.True(t, ok) | |||
| 510 | + | require.Equal(t, tt.msg.Version, version) | |||
| 511 | + | assertAllPrefixesFail(t, got, UnmarshalCommandRequest) | |||
| 512 | + | assertTrailingGarbageFails(t, got, UnmarshalCommandRequest) | |||
| 513 | + | }) | |||
| 514 | + | } | |||
| 515 | + | ||||
| 516 | + | if _, ok := PeekCommandVersion([]byte{0x00}); ok { | |||
| 517 | + | t.Fatal("PeekCommandVersion accepted a one-byte payload") | |||
| 518 | + | } | |||
| 519 | + | } | |||
| 520 | + | ||||
| 521 | + | func TestMarshalCommandRequestRejectsTooManyArguments(t *testing.T) { | |||
| 522 | + | payload, err := MarshalCommandRequest(CommandRequest{ | |||
| 523 | + | Version: ProtocolVersion, | |||
| 524 | + | Slug: "toast", | |||
| 525 | + | Args: make([]string, math.MaxUint16+1), | |||
| 526 | + | }) | |||
| 527 | + | ||||
| 528 | + | require.Nil(t, payload) | |||
| 529 | + | require.ErrorIs(t, err, ErrTooManyCommandArgs) | |||
| 530 | + | } | |||
| 531 | + | ||||
| 532 | + | func TestCommandRequestRejectsImpossibleArgumentCount(t *testing.T) { | |||
| 533 | + | payload, err := MarshalCommandRequest(CommandRequest{Version: ProtocolVersion, Slug: "toast"}) | |||
| 534 | + | require.NoError(t, err) | |||
| 535 | + | argCountOffset := 2 + 1 + 2 + len("toast") | |||
| 536 | + | payload[argCountOffset] = 0xff | |||
| 537 | + | payload[argCountOffset+1] = 0xff | |||
| 538 | + | ||||
| 539 | + | if _, err := UnmarshalCommandRequest(payload); err == nil { | |||
| 540 | + | t.Fatal("UnmarshalCommandRequest accepted an impossible argument count") | |||
| 541 | + | } | |||
| 542 | + | } | |||
| 543 | + | ||||
| 544 | + | func TestCommandResultGoldenAndRoundTrip(t *testing.T) { | |||
| 545 | + | tests := []struct { | |||
| 546 | + | name string | |||
| 547 | + | msg CommandResult | |||
| 548 | + | want []byte | |||
| 549 | + | }{ | |||
| 550 | + | { | |||
| 551 | + | name: "error", | |||
| 552 | + | msg: CommandResult{Code: ErrNoSuchTarget, Text: "no such session"}, | |||
| 553 | + | want: []byte{ | |||
| 554 | + | 0x00, | |||
| 555 | + | 0x00, 0x09, | |||
| 556 | + | 0x00, 0x0f, 'n', 'o', ' ', 's', 'u', 'c', 'h', ' ', 's', 'e', 's', 's', 'i', 'o', 'n', | |||
| 557 | + | 0x00, 0x00, 0x00, 0x00, | |||
| 558 | + | }, | |||
| 559 | + | }, | |||
| 560 | + | { | |||
| 561 | + | name: "success with output", | |||
| 562 | + | msg: CommandResult{OK: true, Text: "listed", Output: "ID\tFOCUSED\n"}, | |||
| 563 | + | want: []byte{ | |||
| 564 | + | 0x01, | |||
| 565 | + | 0x00, 0x00, | |||
| 566 | + | 0x00, 0x06, 'l', 'i', 's', 't', 'e', 'd', | |||
| 567 | + | 0x00, 0x00, 0x00, 0x0b, 'I', 'D', '\t', 'F', 'O', 'C', 'U', 'S', 'E', 'D', '\n', | |||
| 568 | + | }, | |||
| 569 | + | }, | |||
| 570 | + | } | |||
| 571 | + | ||||
| 572 | + | for _, tt := range tests { | |||
| 573 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 574 | + | got := MarshalCommandResult(tt.msg) | |||
| 575 | + | require.Equal(t, tt.want, got) | |||
| 576 | + | back, err := UnmarshalCommandResult(got) | |||
| 577 | + | require.NoError(t, err) | |||
| 578 | + | require.Equal(t, tt.msg, back) | |||
| 579 | + | assertAllPrefixesFail(t, got, UnmarshalCommandResult) | |||
| 580 | + | assertTrailingGarbageFails(t, got, UnmarshalCommandResult) | |||
| 581 | + | }) | |||
| 582 | + | } | |||
| 583 | + | } | |||
| 584 | + | ||||
| 445 | func TestErrorMsgGoldenAndRoundTrip(t *testing.T) { | 585 | func TestErrorMsgGoldenAndRoundTrip(t *testing.T) { | ||
| 446 | msg := ErrorMsg{Code: ErrNameTaken, Text: "name taken"} | 586 | msg := ErrorMsg{Code: ErrNameTaken, Text: "name taken"} | ||
| 447 | want := []byte{0x00, 0x03, 0x00, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x20, 0x74, 0x61, 0x6b, 0x65, 0x6e} | 587 | want := []byte{0x00, 0x03, 0x00, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x20, 0x74, 0x61, 0x6b, 0x65, 0x6e} | ||
M internal/usecase/command/command.go +51 -0
| @@ -72,10 +72,61 @@ const ( | |||
| 72 | 72 | ContextHintRecentSessions | |
| 73 | 73 | ) | |
| 74 | 74 | ||
| 75 | + | // TargetKind declares which resolved target a control command needs. | |
| 76 | + | type TargetKind uint8 | |
| 77 | + | ||
| 78 | + | const ( | |
| 79 | + | TargetNone TargetKind = iota | |
| 80 | + | TargetSession | |
| 81 | + | TargetTab | |
| 82 | + | TargetPane | |
| 83 | + | ) | |
| 84 | + | ||
| 85 | + | // ControlOptions carries per-invocation output preferences. | |
| 86 | + | type ControlOptions struct { | |
| 87 | + | JSON bool | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // ControlResult carries a control command's output. | |
| 91 | + | type ControlResult struct { | |
| 92 | + | Output string | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // ControlContext exposes prompt-free daemon actions to control commands. | |
| 96 | + | type ControlContext interface { | |
| 97 | + | CreateTab() error | |
| 98 | + | CreateSessionNamed(name string) error | |
| 99 | + | CloseTab() error | |
| 100 | + | ClosePane() error | |
| 101 | + | SplitRight() error | |
| 102 | + | SplitLeft() error | |
| 103 | + | SplitUp() error | |
| 104 | + | SplitDown() error | |
| 105 | + | StackPane() error | |
| 106 | + | ToggleStack() error | |
| 107 | + | FocusPaneLeft() error | |
| 108 | + | FocusPaneRight() error | |
| 109 | + | FocusPaneUp() error | |
| 110 | + | FocusPaneDown() error | |
| 111 | + | NextTab() error | |
| 112 | + | PrevTab() error | |
| 113 | + | RenameSessionTo(name string) error | |
| 114 | + | RenameTabTo(name string) error | |
| 115 | + | Toast(severity, message string) error | |
| 116 | + | ListSessions(json bool) (string, error) | |
| 117 | + | ListTabs(json bool) (string, error) | |
| 118 | + | ListPanes(json bool) (string, error) | |
| 119 | + | } | |
| 120 | + | ||
| 75 | 121 | // Command describes an executable command. | |
| 76 | 122 | type Command struct { | |
| 77 | 123 | Slug, Code, Name, Desc string | |
| 124 | + | Usage string | |
| 78 | 125 | Arguments Arguments | |
| 79 | 126 | ContextHint ContextHint | |
| 127 | + | Scriptable bool | |
| 128 | + | PaletteVisible bool | |
| 129 | + | Target TargetKind | |
| 80 | 130 | Run func(Context, []string) error | |
| 131 | + | Control func(ControlContext, []string, ControlOptions) (ControlResult, error) | |
| 81 | 132 | } | |
| @@ -72,10 +72,61 @@ const ( | |||||
| 72 | ContextHintRecentSessions | 72 | ContextHintRecentSessions | ||
| 73 | ) | 73 | ) | ||
| 74 | 74 | ||||
| 75 | + | // TargetKind declares which resolved target a control command needs. | |||
| 76 | + | type TargetKind uint8 | |||
| 77 | + | ||||
| 78 | + | const ( | |||
| 79 | + | TargetNone TargetKind = iota | |||
| 80 | + | TargetSession | |||
| 81 | + | TargetTab | |||
| 82 | + | TargetPane | |||
| 83 | + | ) | |||
| 84 | + | ||||
| 85 | + | // ControlOptions carries per-invocation output preferences. | |||
| 86 | + | type ControlOptions struct { | |||
| 87 | + | JSON bool | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // ControlResult carries a control command's output. | |||
| 91 | + | type ControlResult struct { | |||
| 92 | + | Output string | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // ControlContext exposes prompt-free daemon actions to control commands. | |||
| 96 | + | type ControlContext interface { | |||
| 97 | + | CreateTab() error | |||
| 98 | + | CreateSessionNamed(name string) error | |||
| 99 | + | CloseTab() error | |||
| 100 | + | ClosePane() error | |||
| 101 | + | SplitRight() error | |||
| 102 | + | SplitLeft() error | |||
| 103 | + | SplitUp() error | |||
| 104 | + | SplitDown() error | |||
| 105 | + | StackPane() error | |||
| 106 | + | ToggleStack() error | |||
| 107 | + | FocusPaneLeft() error | |||
| 108 | + | FocusPaneRight() error | |||
| 109 | + | FocusPaneUp() error | |||
| 110 | + | FocusPaneDown() error | |||
| 111 | + | NextTab() error | |||
| 112 | + | PrevTab() error | |||
| 113 | + | RenameSessionTo(name string) error | |||
| 114 | + | RenameTabTo(name string) error | |||
| 115 | + | Toast(severity, message string) error | |||
| 116 | + | ListSessions(json bool) (string, error) | |||
| 117 | + | ListTabs(json bool) (string, error) | |||
| 118 | + | ListPanes(json bool) (string, error) | |||
| 119 | + | } | |||
| 120 | + | ||||
| 75 | // Command describes an executable command. | 121 | // Command describes an executable command. | ||
| 76 | type Command struct { | 122 | type Command struct { | ||
| 77 | Slug, Code, Name, Desc string | 123 | Slug, Code, Name, Desc string | ||
| 124 | + | Usage string | |||
| 78 | Arguments Arguments | 125 | Arguments Arguments | ||
| 79 | ContextHint ContextHint | 126 | ContextHint ContextHint | ||
| 127 | + | Scriptable bool | |||
| 128 | + | PaletteVisible bool | |||
| 129 | + | Target TargetKind | |||
| 80 | Run func(Context, []string) error | 130 | Run func(Context, []string) error | ||
| 131 | + | Control func(ControlContext, []string, ControlOptions) (ControlResult, error) | |||
| 81 | } | 132 | } | ||
M internal/usecase/command/registry.go +101 -29
| @@ -2,54 +2,126 @@ package command | |||
| 2 | 2 | ||
| 3 | 3 | import "strings" | |
| 4 | 4 | ||
| 5 | - | // Registry returns all commands in display order. | |
| 5 | + | // Registry returns all palette and control commands in stable display order. | |
| 6 | 6 | func Registry() []Command { | |
| 7 | 7 | commands := []Command{ | |
| 8 | - | {Slug: "new-tab", Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context, _ []string) error { return ctx.CreateTab() }}, | |
| 9 | - | {Slug: "new-session", Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context, _ []string) error { return ctx.CreateSession() }}, | |
| 10 | - | {Slug: "close-tab", Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context, _ []string) error { return ctx.CloseTab() }}, | |
| 11 | - | {Slug: "split-right", Code: "SPR", Name: "Split right", Desc: "Split the focused pane to the right", Run: func(ctx Context, _ []string) error { return ctx.SplitRight() }}, | |
| 12 | - | {Slug: "split-left", Code: "SPL", Name: "Split left", Desc: "Split the focused pane to the left", Run: func(ctx Context, _ []string) error { return ctx.SplitLeft() }}, | |
| 13 | - | {Slug: "split-up", Code: "SPU", Name: "Split up", Desc: "Split the focused pane upward", Run: func(ctx Context, _ []string) error { return ctx.SplitUp() }}, | |
| 14 | - | {Slug: "split-down", Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context, _ []string) error { return ctx.SplitDown() }}, | |
| 15 | - | {Slug: "stack-pane", Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context, _ []string) error { return ctx.StackPane() }}, | |
| 16 | - | {Slug: "toggle-stack", Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context, _ []string) error { return ctx.ToggleStack() }}, | |
| 17 | - | {Slug: "toggle-floating-pane", Code: "FLT", Name: "Toggle floating pane", Desc: "Toggle the floating pane", Run: func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }}, | |
| 18 | - | {Slug: "close-pane", Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context, _ []string) error { return ctx.ClosePane() }}, | |
| 19 | - | {Slug: "focus-pane-left", Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneLeft() }}, | |
| 20 | - | {Slug: "focus-pane-right", Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneRight() }}, | |
| 21 | - | {Slug: "focus-pane-up", Code: "FPU", Name: "Focus pane up", Desc: "Focus the pane above", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneUp() }}, | |
| 22 | - | {Slug: "focus-pane-down", Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneDown() }}, | |
| 23 | - | {Slug: "next-tab", Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context, _ []string) error { return ctx.NextTab() }}, | |
| 24 | - | {Slug: "previous-tab", Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context, _ []string) error { return ctx.PrevTab() }}, | |
| 25 | - | {Slug: "back-session", Code: "BSK", Name: "Previous session", Desc: "Toggle the previously active session", Run: func(ctx Context, _ []string) error { return ctx.BackSession() }}, | |
| 26 | - | {Slug: "jump-recent-session", Code: "JRS", Name: "Jump to recent session", Desc: "Jump to a recent session by rank", Arguments: ArgumentsRequired, ContextHint: ContextHintRecentSessions, Run: func(ctx Context, args []string) error { | |
| 8 | + | paletteControl("new-tab", "CNT", "New tab", "Create a new tab", TargetSession, func(ctx Context, _ []string) error { return ctx.CreateTab() }, func(ctx ControlContext) error { return ctx.CreateTab() }), | |
| 9 | + | paletteControlOne("new-session", "CNS", "New session", "Create and switch to a named session", TargetSession, func(ctx Context, _ []string) error { return ctx.CreateSession() }, func(ctx ControlContext, name string) error { return ctx.CreateSessionNamed(name) }), | |
| 10 | + | paletteControl("close-tab", "CLT", "Close tab", "Close the current tab", TargetTab, func(ctx Context, _ []string) error { return ctx.CloseTab() }, func(ctx ControlContext) error { return ctx.CloseTab() }), | |
| 11 | + | paletteControl("split-right", "SPR", "Split right", "Split the focused pane to the right", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitRight() }, func(ctx ControlContext) error { return ctx.SplitRight() }), | |
| 12 | + | paletteControl("split-left", "SPL", "Split left", "Split the focused pane to the left", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitLeft() }, func(ctx ControlContext) error { return ctx.SplitLeft() }), | |
| 13 | + | paletteControl("split-up", "SPU", "Split up", "Split the focused pane upward", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitUp() }, func(ctx ControlContext) error { return ctx.SplitUp() }), | |
| 14 | + | paletteControl("split-down", "SPD", "Split down", "Split the focused pane downward", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitDown() }, func(ctx ControlContext) error { return ctx.SplitDown() }), | |
| 15 | + | paletteControl("stack-pane", "STP", "Stack pane", "Create a new pane in a stack", TargetPane, func(ctx Context, _ []string) error { return ctx.StackPane() }, func(ctx ControlContext) error { return ctx.StackPane() }), | |
| 16 | + | paletteControl("toggle-stack", "TST", "Toggle stack", "Toggle the focused pane stack", TargetPane, func(ctx Context, _ []string) error { return ctx.ToggleStack() }, func(ctx ControlContext) error { return ctx.ToggleStack() }), | |
| 17 | + | paletteOnly("toggle-floating-pane", "FLT", "Toggle floating pane", "Toggle the floating pane", func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }), | |
| 18 | + | paletteControl("close-pane", "CLP", "Close pane", "Close the focused pane", TargetPane, func(ctx Context, _ []string) error { return ctx.ClosePane() }, func(ctx ControlContext) error { return ctx.ClosePane() }), | |
| 19 | + | paletteControl("focus-pane-left", "FPL", "Focus pane left", "Focus the pane to the left", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneLeft() }, func(ctx ControlContext) error { return ctx.FocusPaneLeft() }), | |
| 20 | + | paletteControl("focus-pane-right", "FPR", "Focus pane right", "Focus the pane to the right", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneRight() }, func(ctx ControlContext) error { return ctx.FocusPaneRight() }), | |
| 21 | + | paletteControl("focus-pane-up", "FPU", "Focus pane up", "Focus the pane above", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneUp() }, func(ctx ControlContext) error { return ctx.FocusPaneUp() }), | |
| 22 | + | paletteControl("focus-pane-down", "FPD", "Focus pane down", "Focus the pane below", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneDown() }, func(ctx ControlContext) error { return ctx.FocusPaneDown() }), | |
| 23 | + | paletteControl("next-tab", "NXT", "Next tab", "Switch to the next tab", TargetSession, func(ctx Context, _ []string) error { return ctx.NextTab() }, func(ctx ControlContext) error { return ctx.NextTab() }), | |
| 24 | + | paletteControl("previous-tab", "PVT", "Previous tab", "Switch to the previous tab", TargetSession, func(ctx Context, _ []string) error { return ctx.PrevTab() }, func(ctx ControlContext) error { return ctx.PrevTab() }), | |
| 25 | + | paletteOnly("back-session", "BSK", "Previous session", "Toggle the previously active session", func(ctx Context, _ []string) error { return ctx.BackSession() }), | |
| 26 | + | {Slug: "jump-recent-session", Code: "JRS", Name: "Jump to recent session", Desc: "Jump to a recent session by rank", Usage: "jump-recent-session <rank>", PaletteVisible: true, Arguments: ArgumentsRequired, ContextHint: ContextHintRecentSessions, Run: func(ctx Context, args []string) error { | |
| 27 | 27 | rank, err := ParsePositiveDecimal(args) | |
| 28 | 28 | if err != nil { | |
| 29 | 29 | return err | |
| 30 | 30 | } | |
| 31 | 31 | return ctx.JumpRecentSession(rank) | |
| 32 | 32 | }}, | |
| 33 | - | {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }}, | |
| 34 | - | {Slug: "notifications", Code: "NTC", Name: "Notifications", Desc: "Show notification history", Run: func(ctx Context, _ []string) error { return ctx.OpenNotifications() }}, | |
| 35 | - | {Slug: "yank-last-notification", Code: "YLN", Name: "Yank last notification", Desc: "Copy the most recent notification's details to the clipboard", Run: func(ctx Context, _ []string) error { return ctx.YankLastNotification() }}, | |
| 36 | - | {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }}, | |
| 37 | - | {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context, _ []string) error { return ctx.RenameSession() }}, | |
| 38 | - | {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context, _ []string) error { return ctx.RenameTab() }}, | |
| 39 | - | {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context, _ []string) error { return ctx.Detach() }}, | |
| 33 | + | paletteOnly("session-picker", "SSP", "Session picker", "Open the session picker", func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }), | |
| 34 | + | paletteOnly("notifications", "NTC", "Notifications", "Show notification history", func(ctx Context, _ []string) error { return ctx.OpenNotifications() }), | |
| 35 | + | paletteOnly("yank-last-notification", "YLN", "Yank last notification", "Copy the most recent notification's details to the clipboard", func(ctx Context, _ []string) error { return ctx.YankLastNotification() }), | |
| 36 | + | paletteOnly("visual-mode", "VIS", "Visual mode", "Enter visual mode", func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }), | |
| 37 | + | paletteControlOne("rename-session", "RNS", "Rename session", "Rename the session (an ephemeral session becomes named)", TargetSession, func(ctx Context, _ []string) error { return ctx.RenameSession() }, func(ctx ControlContext, name string) error { return ctx.RenameSessionTo(name) }), | |
| 38 | + | paletteControlOne("rename-tab", "RNT", "Rename tab", "Rename the current tab", TargetTab, func(ctx Context, _ []string) error { return ctx.RenameTab() }, func(ctx ControlContext, name string) error { return ctx.RenameTabTo(name) }), | |
| 39 | + | paletteOnly("detach", "DET", "Detach", "Detach from the session", func(ctx Context, _ []string) error { return ctx.Detach() }), | |
| 40 | + | toastCommand(), | |
| 41 | + | listCommand("list-sessions", "List sessions", "List sessions with active markers", TargetNone, func(ctx ControlContext, json bool) (string, error) { return ctx.ListSessions(json) }), | |
| 42 | + | listCommand("list-tabs", "List tabs", "List tabs in the target session", TargetSession, func(ctx ControlContext, json bool) (string, error) { return ctx.ListTabs(json) }), | |
| 43 | + | listCommand("list-panes", "List panes", "List panes in the target tab", TargetTab, func(ctx ControlContext, json bool) (string, error) { return ctx.ListPanes(json) }), | |
| 40 | 44 | } | |
| 41 | - | ||
| 42 | 45 | return commands | |
| 43 | 46 | } | |
| 44 | 47 | ||
| 48 | + | func paletteOnly(slug, code, name, desc string, run func(Context, []string) error) Command { | |
| 49 | + | return Command{Slug: slug, Code: code, Name: name, Desc: desc, Usage: slug, PaletteVisible: true, Run: run} | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func paletteControl(slug, code, name, desc string, target TargetKind, run func(Context, []string) error, control func(ControlContext) error) Command { | |
| 53 | + | return Command{Slug: slug, Code: code, Name: name, Desc: desc, Usage: slug, PaletteVisible: true, Scriptable: true, Target: target, Run: run, Control: func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) { | |
| 54 | + | if len(args) != 0 { | |
| 55 | + | return ControlResult{}, ErrInvalidArguments | |
| 56 | + | } | |
| 57 | + | return ControlResult{}, control(ctx) | |
| 58 | + | }} | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func paletteControlOne(slug, code, name, desc string, target TargetKind, run func(Context, []string) error, control func(ControlContext, string) error) Command { | |
| 62 | + | cmd := Command{Slug: slug, Code: code, Name: name, Desc: desc, Usage: slug + " <name>", PaletteVisible: true, Scriptable: true, Target: target, Run: run} | |
| 63 | + | cmd.Control = func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) { | |
| 64 | + | if len(args) != 1 || args[0] == "" { | |
| 65 | + | return ControlResult{}, ErrInvalidArguments | |
| 66 | + | } | |
| 67 | + | return ControlResult{}, control(ctx, args[0]) | |
| 68 | + | } | |
| 69 | + | return cmd | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func toastCommand() Command { | |
| 73 | + | return Command{Slug: "toast", Name: "Toast", Desc: "Show a toast notification in the target session", Usage: "toast [-l info|warn|error] <message>", Scriptable: true, Target: TargetSession, Control: func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) { | |
| 74 | + | severity := "info" | |
| 75 | + | if len(args) >= 2 && args[0] == "-l" { | |
| 76 | + | severity = args[1] | |
| 77 | + | args = args[2:] | |
| 78 | + | } | |
| 79 | + | if len(args) != 1 || args[0] == "" { | |
| 80 | + | return ControlResult{}, ErrInvalidArguments | |
| 81 | + | } | |
| 82 | + | return ControlResult{}, ctx.Toast(severity, args[0]) | |
| 83 | + | }} | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func listCommand(slug, name, desc string, target TargetKind, list func(ControlContext, bool) (string, error)) Command { | |
| 87 | + | return Command{Slug: slug, Name: name, Desc: desc, Usage: slug + " [--json]", Scriptable: true, Target: target, Control: func(ctx ControlContext, args []string, opts ControlOptions) (ControlResult, error) { | |
| 88 | + | if len(args) != 0 { | |
| 89 | + | return ControlResult{}, ErrInvalidArguments | |
| 90 | + | } | |
| 91 | + | out, err := list(ctx, opts.JSON) | |
| 92 | + | return ControlResult{Output: out}, err | |
| 93 | + | }} | |
| 94 | + | } | |
| 95 | + | ||
| 45 | 96 | // ByCode returns the command matching code, case-insensitively. | |
| 46 | 97 | func ByCode(code string) (Command, bool) { | |
| 47 | 98 | code = strings.ToUpper(code) | |
| 48 | - | for _, cmd := range Registry() { | |
| 99 | + | for _, cmd := range PaletteRegistry() { | |
| 49 | 100 | if cmd.Code == code { | |
| 50 | 101 | return cmd, true | |
| 51 | 102 | } | |
| 52 | 103 | } | |
| 104 | + | return Command{}, false | |
| 105 | + | } | |
| 53 | 106 | ||
| 107 | + | // BySlug returns the exact command slug. Slugs are stable CLI identifiers. | |
| 108 | + | func BySlug(slug string) (Command, bool) { | |
| 109 | + | for _, cmd := range Registry() { | |
| 110 | + | if cmd.Slug == slug { | |
| 111 | + | return cmd, true | |
| 112 | + | } | |
| 113 | + | } | |
| 54 | 114 | return Command{}, false | |
| 55 | 115 | } | |
| 116 | + | ||
| 117 | + | // PaletteRegistry returns commands visible in the in-band palette. | |
| 118 | + | func PaletteRegistry() []Command { | |
| 119 | + | all := Registry() | |
| 120 | + | out := make([]Command, 0, len(all)) | |
| 121 | + | for _, cmd := range all { | |
| 122 | + | if cmd.PaletteVisible { | |
| 123 | + | out = append(out, cmd) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | return out | |
| 127 | + | } | |
| @@ -2,54 +2,126 @@ package command | |||||
| 2 | 2 | ||||
| 3 | import "strings" | 3 | import "strings" | ||
| 4 | 4 | ||||
| 5 | - | // Registry returns all commands in display order. | 5 | + | // Registry returns all palette and control commands in stable display order. |
| 6 | func Registry() []Command { | 6 | func Registry() []Command { | ||
| 7 | commands := []Command{ | 7 | commands := []Command{ | ||
| 8 | - | {Slug: "new-tab", Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context, _ []string) error { return ctx.CreateTab() }}, | 8 | + | paletteControl("new-tab", "CNT", "New tab", "Create a new tab", TargetSession, func(ctx Context, _ []string) error { return ctx.CreateTab() }, func(ctx ControlContext) error { return ctx.CreateTab() }), |
| 9 | - | {Slug: "new-session", Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context, _ []string) error { return ctx.CreateSession() }}, | 9 | + | paletteControlOne("new-session", "CNS", "New session", "Create and switch to a named session", TargetSession, func(ctx Context, _ []string) error { return ctx.CreateSession() }, func(ctx ControlContext, name string) error { return ctx.CreateSessionNamed(name) }), |
| 10 | - | {Slug: "close-tab", Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context, _ []string) error { return ctx.CloseTab() }}, | 10 | + | paletteControl("close-tab", "CLT", "Close tab", "Close the current tab", TargetTab, func(ctx Context, _ []string) error { return ctx.CloseTab() }, func(ctx ControlContext) error { return ctx.CloseTab() }), |
| 11 | - | {Slug: "split-right", Code: "SPR", Name: "Split right", Desc: "Split the focused pane to the right", Run: func(ctx Context, _ []string) error { return ctx.SplitRight() }}, | 11 | + | paletteControl("split-right", "SPR", "Split right", "Split the focused pane to the right", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitRight() }, func(ctx ControlContext) error { return ctx.SplitRight() }), |
| 12 | - | {Slug: "split-left", Code: "SPL", Name: "Split left", Desc: "Split the focused pane to the left", Run: func(ctx Context, _ []string) error { return ctx.SplitLeft() }}, | 12 | + | paletteControl("split-left", "SPL", "Split left", "Split the focused pane to the left", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitLeft() }, func(ctx ControlContext) error { return ctx.SplitLeft() }), |
| 13 | - | {Slug: "split-up", Code: "SPU", Name: "Split up", Desc: "Split the focused pane upward", Run: func(ctx Context, _ []string) error { return ctx.SplitUp() }}, | 13 | + | paletteControl("split-up", "SPU", "Split up", "Split the focused pane upward", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitUp() }, func(ctx ControlContext) error { return ctx.SplitUp() }), |
| 14 | - | {Slug: "split-down", Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context, _ []string) error { return ctx.SplitDown() }}, | 14 | + | paletteControl("split-down", "SPD", "Split down", "Split the focused pane downward", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitDown() }, func(ctx ControlContext) error { return ctx.SplitDown() }), |
| 15 | - | {Slug: "stack-pane", Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context, _ []string) error { return ctx.StackPane() }}, | 15 | + | paletteControl("stack-pane", "STP", "Stack pane", "Create a new pane in a stack", TargetPane, func(ctx Context, _ []string) error { return ctx.StackPane() }, func(ctx ControlContext) error { return ctx.StackPane() }), |
| 16 | - | {Slug: "toggle-stack", Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context, _ []string) error { return ctx.ToggleStack() }}, | 16 | + | paletteControl("toggle-stack", "TST", "Toggle stack", "Toggle the focused pane stack", TargetPane, func(ctx Context, _ []string) error { return ctx.ToggleStack() }, func(ctx ControlContext) error { return ctx.ToggleStack() }), |
| 17 | - | {Slug: "toggle-floating-pane", Code: "FLT", Name: "Toggle floating pane", Desc: "Toggle the floating pane", Run: func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }}, | 17 | + | paletteOnly("toggle-floating-pane", "FLT", "Toggle floating pane", "Toggle the floating pane", func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }), |
| 18 | - | {Slug: "close-pane", Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context, _ []string) error { return ctx.ClosePane() }}, | 18 | + | paletteControl("close-pane", "CLP", "Close pane", "Close the focused pane", TargetPane, func(ctx Context, _ []string) error { return ctx.ClosePane() }, func(ctx ControlContext) error { return ctx.ClosePane() }), |
| 19 | - | {Slug: "focus-pane-left", Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneLeft() }}, | 19 | + | paletteControl("focus-pane-left", "FPL", "Focus pane left", "Focus the pane to the left", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneLeft() }, func(ctx ControlContext) error { return ctx.FocusPaneLeft() }), |
| 20 | - | {Slug: "focus-pane-right", Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneRight() }}, | 20 | + | paletteControl("focus-pane-right", "FPR", "Focus pane right", "Focus the pane to the right", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneRight() }, func(ctx ControlContext) error { return ctx.FocusPaneRight() }), |
| 21 | - | {Slug: "focus-pane-up", Code: "FPU", Name: "Focus pane up", Desc: "Focus the pane above", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneUp() }}, | 21 | + | paletteControl("focus-pane-up", "FPU", "Focus pane up", "Focus the pane above", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneUp() }, func(ctx ControlContext) error { return ctx.FocusPaneUp() }), |
| 22 | - | {Slug: "focus-pane-down", Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneDown() }}, | 22 | + | paletteControl("focus-pane-down", "FPD", "Focus pane down", "Focus the pane below", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneDown() }, func(ctx ControlContext) error { return ctx.FocusPaneDown() }), |
| 23 | - | {Slug: "next-tab", Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context, _ []string) error { return ctx.NextTab() }}, | 23 | + | paletteControl("next-tab", "NXT", "Next tab", "Switch to the next tab", TargetSession, func(ctx Context, _ []string) error { return ctx.NextTab() }, func(ctx ControlContext) error { return ctx.NextTab() }), |
| 24 | - | {Slug: "previous-tab", Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context, _ []string) error { return ctx.PrevTab() }}, | 24 | + | paletteControl("previous-tab", "PVT", "Previous tab", "Switch to the previous tab", TargetSession, func(ctx Context, _ []string) error { return ctx.PrevTab() }, func(ctx ControlContext) error { return ctx.PrevTab() }), |
| 25 | - | {Slug: "back-session", Code: "BSK", Name: "Previous session", Desc: "Toggle the previously active session", Run: func(ctx Context, _ []string) error { return ctx.BackSession() }}, | 25 | + | paletteOnly("back-session", "BSK", "Previous session", "Toggle the previously active session", func(ctx Context, _ []string) error { return ctx.BackSession() }), |
| 26 | - | {Slug: "jump-recent-session", Code: "JRS", Name: "Jump to recent session", Desc: "Jump to a recent session by rank", Arguments: ArgumentsRequired, ContextHint: ContextHintRecentSessions, Run: func(ctx Context, args []string) error { | 26 | + | {Slug: "jump-recent-session", Code: "JRS", Name: "Jump to recent session", Desc: "Jump to a recent session by rank", Usage: "jump-recent-session <rank>", PaletteVisible: true, Arguments: ArgumentsRequired, ContextHint: ContextHintRecentSessions, Run: func(ctx Context, args []string) error { |
| 27 | rank, err := ParsePositiveDecimal(args) | 27 | rank, err := ParsePositiveDecimal(args) | ||
| 28 | if err != nil { | 28 | if err != nil { | ||
| 29 | return err | 29 | return err | ||
| 30 | } | 30 | } | ||
| 31 | return ctx.JumpRecentSession(rank) | 31 | return ctx.JumpRecentSession(rank) | ||
| 32 | }}, | 32 | }}, | ||
| 33 | - | {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }}, | 33 | + | paletteOnly("session-picker", "SSP", "Session picker", "Open the session picker", func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }), |
| 34 | - | {Slug: "notifications", Code: "NTC", Name: "Notifications", Desc: "Show notification history", Run: func(ctx Context, _ []string) error { return ctx.OpenNotifications() }}, | 34 | + | paletteOnly("notifications", "NTC", "Notifications", "Show notification history", func(ctx Context, _ []string) error { return ctx.OpenNotifications() }), |
| 35 | - | {Slug: "yank-last-notification", Code: "YLN", Name: "Yank last notification", Desc: "Copy the most recent notification's details to the clipboard", Run: func(ctx Context, _ []string) error { return ctx.YankLastNotification() }}, | 35 | + | paletteOnly("yank-last-notification", "YLN", "Yank last notification", "Copy the most recent notification's details to the clipboard", func(ctx Context, _ []string) error { return ctx.YankLastNotification() }), |
| 36 | - | {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }}, | 36 | + | paletteOnly("visual-mode", "VIS", "Visual mode", "Enter visual mode", func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }), |
| 37 | - | {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context, _ []string) error { return ctx.RenameSession() }}, | 37 | + | paletteControlOne("rename-session", "RNS", "Rename session", "Rename the session (an ephemeral session becomes named)", TargetSession, func(ctx Context, _ []string) error { return ctx.RenameSession() }, func(ctx ControlContext, name string) error { return ctx.RenameSessionTo(name) }), |
| 38 | - | {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context, _ []string) error { return ctx.RenameTab() }}, | 38 | + | paletteControlOne("rename-tab", "RNT", "Rename tab", "Rename the current tab", TargetTab, func(ctx Context, _ []string) error { return ctx.RenameTab() }, func(ctx ControlContext, name string) error { return ctx.RenameTabTo(name) }), |
| 39 | - | {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context, _ []string) error { return ctx.Detach() }}, | 39 | + | paletteOnly("detach", "DET", "Detach", "Detach from the session", func(ctx Context, _ []string) error { return ctx.Detach() }), |
| 40 | + | toastCommand(), | |||
| 41 | + | listCommand("list-sessions", "List sessions", "List sessions with active markers", TargetNone, func(ctx ControlContext, json bool) (string, error) { return ctx.ListSessions(json) }), | |||
| 42 | + | listCommand("list-tabs", "List tabs", "List tabs in the target session", TargetSession, func(ctx ControlContext, json bool) (string, error) { return ctx.ListTabs(json) }), | |||
| 43 | + | listCommand("list-panes", "List panes", "List panes in the target tab", TargetTab, func(ctx ControlContext, json bool) (string, error) { return ctx.ListPanes(json) }), | |||
| 40 | } | 44 | } | ||
| 41 | - | ||||
| 42 | return commands | 45 | return commands | ||
| 43 | } | 46 | } | ||
| 44 | 47 | ||||
| 48 | + | func paletteOnly(slug, code, name, desc string, run func(Context, []string) error) Command { | |||
| 49 | + | return Command{Slug: slug, Code: code, Name: name, Desc: desc, Usage: slug, PaletteVisible: true, Run: run} | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func paletteControl(slug, code, name, desc string, target TargetKind, run func(Context, []string) error, control func(ControlContext) error) Command { | |||
| 53 | + | return Command{Slug: slug, Code: code, Name: name, Desc: desc, Usage: slug, PaletteVisible: true, Scriptable: true, Target: target, Run: run, Control: func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) { | |||
| 54 | + | if len(args) != 0 { | |||
| 55 | + | return ControlResult{}, ErrInvalidArguments | |||
| 56 | + | } | |||
| 57 | + | return ControlResult{}, control(ctx) | |||
| 58 | + | }} | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func paletteControlOne(slug, code, name, desc string, target TargetKind, run func(Context, []string) error, control func(ControlContext, string) error) Command { | |||
| 62 | + | cmd := Command{Slug: slug, Code: code, Name: name, Desc: desc, Usage: slug + " <name>", PaletteVisible: true, Scriptable: true, Target: target, Run: run} | |||
| 63 | + | cmd.Control = func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) { | |||
| 64 | + | if len(args) != 1 || args[0] == "" { | |||
| 65 | + | return ControlResult{}, ErrInvalidArguments | |||
| 66 | + | } | |||
| 67 | + | return ControlResult{}, control(ctx, args[0]) | |||
| 68 | + | } | |||
| 69 | + | return cmd | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func toastCommand() Command { | |||
| 73 | + | return Command{Slug: "toast", Name: "Toast", Desc: "Show a toast notification in the target session", Usage: "toast [-l info|warn|error] <message>", Scriptable: true, Target: TargetSession, Control: func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) { | |||
| 74 | + | severity := "info" | |||
| 75 | + | if len(args) >= 2 && args[0] == "-l" { | |||
| 76 | + | severity = args[1] | |||
| 77 | + | args = args[2:] | |||
| 78 | + | } | |||
| 79 | + | if len(args) != 1 || args[0] == "" { | |||
| 80 | + | return ControlResult{}, ErrInvalidArguments | |||
| 81 | + | } | |||
| 82 | + | return ControlResult{}, ctx.Toast(severity, args[0]) | |||
| 83 | + | }} | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func listCommand(slug, name, desc string, target TargetKind, list func(ControlContext, bool) (string, error)) Command { | |||
| 87 | + | return Command{Slug: slug, Name: name, Desc: desc, Usage: slug + " [--json]", Scriptable: true, Target: target, Control: func(ctx ControlContext, args []string, opts ControlOptions) (ControlResult, error) { | |||
| 88 | + | if len(args) != 0 { | |||
| 89 | + | return ControlResult{}, ErrInvalidArguments | |||
| 90 | + | } | |||
| 91 | + | out, err := list(ctx, opts.JSON) | |||
| 92 | + | return ControlResult{Output: out}, err | |||
| 93 | + | }} | |||
| 94 | + | } | |||
| 95 | + | ||||
| 45 | // ByCode returns the command matching code, case-insensitively. | 96 | // ByCode returns the command matching code, case-insensitively. | ||
| 46 | func ByCode(code string) (Command, bool) { | 97 | func ByCode(code string) (Command, bool) { | ||
| 47 | code = strings.ToUpper(code) | 98 | code = strings.ToUpper(code) | ||
| 48 | - | for _, cmd := range Registry() { | 99 | + | for _, cmd := range PaletteRegistry() { |
| 49 | if cmd.Code == code { | 100 | if cmd.Code == code { | ||
| 50 | return cmd, true | 101 | return cmd, true | ||
| 51 | } | 102 | } | ||
| 52 | } | 103 | } | ||
| 104 | + | return Command{}, false | |||
| 105 | + | } | |||
| 53 | 106 | ||||
| 107 | + | // BySlug returns the exact command slug. Slugs are stable CLI identifiers. | |||
| 108 | + | func BySlug(slug string) (Command, bool) { | |||
| 109 | + | for _, cmd := range Registry() { | |||
| 110 | + | if cmd.Slug == slug { | |||
| 111 | + | return cmd, true | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 54 | return Command{}, false | 114 | return Command{}, false | ||
| 55 | } | 115 | } | ||
| 116 | + | ||||
| 117 | + | // PaletteRegistry returns commands visible in the in-band palette. | |||
| 118 | + | func PaletteRegistry() []Command { | |||
| 119 | + | all := Registry() | |||
| 120 | + | out := make([]Command, 0, len(all)) | |||
| 121 | + | for _, cmd := range all { | |||
| 122 | + | if cmd.PaletteVisible { | |||
| 123 | + | out = append(out, cmd) | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | return out | |||
| 127 | + | } | |||
M internal/usecase/command/registry_test.go +141 -1
| @@ -1,12 +1,14 @@ | |||
| 1 | 1 | package command | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "errors" | |
| 4 | 5 | "regexp" | |
| 6 | + | "strconv" | |
| 5 | 7 | "testing" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| 8 | 10 | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | |
| 9 | - | commands := Registry() | |
| 11 | + | commands := PaletteRegistry() | |
| 10 | 12 | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"} | |
| 11 | 13 | ||
| 12 | 14 | if len(commands) != len(wantCodes) { | |
| @@ -37,6 +39,144 @@ func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | |||
| 37 | 39 | } | |
| 38 | 40 | } | |
| 39 | 41 | ||
| 42 | + | func TestBySlugExactMatch(t *testing.T) { | |
| 43 | + | tests := []struct { | |
| 44 | + | slug string | |
| 45 | + | ok bool | |
| 46 | + | }{ | |
| 47 | + | {slug: "split-right", ok: true}, | |
| 48 | + | {slug: "SPLIT-RIGHT", ok: false}, | |
| 49 | + | {slug: "toast", ok: true}, | |
| 50 | + | {slug: "list-panes", ok: true}, | |
| 51 | + | {slug: "does-not-exist", ok: false}, | |
| 52 | + | } | |
| 53 | + | for _, tt := range tests { | |
| 54 | + | t.Run(tt.slug, func(t *testing.T) { | |
| 55 | + | if _, ok := BySlug(tt.slug); ok != tt.ok { | |
| 56 | + | t.Fatalf("BySlug(%q) ok = %v, want %v", tt.slug, ok, tt.ok) | |
| 57 | + | } | |
| 58 | + | }) | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestRegistryControlMetadata(t *testing.T) { | |
| 63 | + | scriptable := map[string]TargetKind{ | |
| 64 | + | "split-right": TargetPane, "split-left": TargetPane, "split-up": TargetPane, | |
| 65 | + | "split-down": TargetPane, "stack-pane": TargetPane, "toggle-stack": TargetPane, | |
| 66 | + | "close-pane": TargetPane, "focus-pane-left": TargetPane, "focus-pane-right": TargetPane, | |
| 67 | + | "focus-pane-up": TargetPane, "focus-pane-down": TargetPane, | |
| 68 | + | "new-tab": TargetSession, "close-tab": TargetTab, "next-tab": TargetSession, | |
| 69 | + | "previous-tab": TargetSession, "rename-session": TargetSession, "rename-tab": TargetTab, | |
| 70 | + | "new-session": TargetSession, "toast": TargetSession, | |
| 71 | + | "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab, | |
| 72 | + | } | |
| 73 | + | notScriptable := []string{ | |
| 74 | + | "session-picker", "notifications", "visual-mode", "yank-last-notification", | |
| 75 | + | "detach", "back-session", "jump-recent-session", "toggle-floating-pane", | |
| 76 | + | } | |
| 77 | + | for slug, target := range scriptable { | |
| 78 | + | cmd, ok := BySlug(slug) | |
| 79 | + | if !ok || !cmd.Scriptable { | |
| 80 | + | t.Errorf("%s: want scriptable entry, ok=%v scriptable=%v", slug, ok, cmd.Scriptable) | |
| 81 | + | continue | |
| 82 | + | } | |
| 83 | + | if cmd.Target != target || cmd.Control == nil || cmd.Usage == "" || cmd.Desc == "" { | |
| 84 | + | t.Errorf("%s: incomplete control metadata: %#v", slug, cmd) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | for _, slug := range notScriptable { | |
| 88 | + | cmd, ok := BySlug(slug) | |
| 89 | + | if !ok || cmd.Scriptable || cmd.Control != nil { | |
| 90 | + | t.Errorf("%s: want present and non-scriptable, got %#v, ok=%v", slug, cmd, ok) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestPaletteRegistryHidesAPIOnly(t *testing.T) { | |
| 96 | + | for _, cmd := range PaletteRegistry() { | |
| 97 | + | if !cmd.PaletteVisible || cmd.Run == nil { | |
| 98 | + | t.Errorf("palette command is not executable and visible: %#v", cmd) | |
| 99 | + | } | |
| 100 | + | if cmd.Slug == "toast" || cmd.Slug == "list-sessions" || cmd.Slug == "list-tabs" || cmd.Slug == "list-panes" { | |
| 101 | + | t.Errorf("API-only command %q is visible", cmd.Slug) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestControlHandlersValidateAndDelegate(t *testing.T) { | |
| 107 | + | tests := []struct { | |
| 108 | + | name string | |
| 109 | + | slug string | |
| 110 | + | args []string | |
| 111 | + | opts ControlOptions | |
| 112 | + | wantCall string | |
| 113 | + | wantOutput string | |
| 114 | + | wantErr error | |
| 115 | + | }{ | |
| 116 | + | {name: "zero argument mutation delegates", slug: "split-right", wantCall: "split-right"}, | |
| 117 | + | {name: "zero argument mutation rejects args", slug: "split-right", args: []string{"extra"}, wantErr: ErrInvalidArguments}, | |
| 118 | + | {name: "rename delegates name", slug: "rename-tab", args: []string{"editor"}, wantCall: "rename-tab:editor"}, | |
| 119 | + | {name: "rename rejects empty name", slug: "rename-tab", args: []string{""}, wantErr: ErrInvalidArguments}, | |
| 120 | + | {name: "toast delegates severity", slug: "toast", args: []string{"-l", "warn", "hello"}, wantCall: "toast:warn:hello"}, | |
| 121 | + | {name: "toast rejects malformed flags", slug: "toast", args: []string{"-l", "warn"}, wantErr: ErrInvalidArguments}, | |
| 122 | + | {name: "query delegates JSON option", slug: "list-panes", opts: ControlOptions{JSON: true}, wantCall: "list-panes:true", wantOutput: "panes"}, | |
| 123 | + | {name: "query rejects args", slug: "list-panes", args: []string{"extra"}, wantErr: ErrInvalidArguments}, | |
| 124 | + | } | |
| 125 | + | for _, tt := range tests { | |
| 126 | + | t.Run(tt.name, func(t *testing.T) { | |
| 127 | + | cmd, ok := BySlug(tt.slug) | |
| 128 | + | if !ok { | |
| 129 | + | t.Fatalf("BySlug(%q) missing", tt.slug) | |
| 130 | + | } | |
| 131 | + | ctx := &controlSpy{} | |
| 132 | + | got, err := cmd.Control(ctx, tt.args, tt.opts) | |
| 133 | + | if !errors.Is(err, tt.wantErr) { | |
| 134 | + | t.Fatalf("Control() error = %v, want %v", err, tt.wantErr) | |
| 135 | + | } | |
| 136 | + | if ctx.call != tt.wantCall || got.Output != tt.wantOutput { | |
| 137 | + | t.Fatalf("Control() call/output = %q/%q, want %q/%q", ctx.call, got.Output, tt.wantCall, tt.wantOutput) | |
| 138 | + | } | |
| 139 | + | }) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | type controlSpy struct{ call string } | |
| 144 | + | ||
| 145 | + | func (s *controlSpy) record(call string) error { s.call = call; return nil } | |
| 146 | + | func (s *controlSpy) CreateTab() error { return s.record("new-tab") } | |
| 147 | + | func (s *controlSpy) CreateSessionNamed(v string) error { return s.record("new-session:" + v) } | |
| 148 | + | func (s *controlSpy) CloseTab() error { return s.record("close-tab") } | |
| 149 | + | func (s *controlSpy) ClosePane() error { return s.record("close-pane") } | |
| 150 | + | func (s *controlSpy) SplitRight() error { return s.record("split-right") } | |
| 151 | + | func (s *controlSpy) SplitLeft() error { return s.record("split-left") } | |
| 152 | + | func (s *controlSpy) SplitUp() error { return s.record("split-up") } | |
| 153 | + | func (s *controlSpy) SplitDown() error { return s.record("split-down") } | |
| 154 | + | func (s *controlSpy) StackPane() error { return s.record("stack-pane") } | |
| 155 | + | func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") } | |
| 156 | + | func (s *controlSpy) FocusPaneLeft() error { return s.record("focus-pane-left") } | |
| 157 | + | func (s *controlSpy) FocusPaneRight() error { return s.record("focus-pane-right") } | |
| 158 | + | func (s *controlSpy) FocusPaneUp() error { return s.record("focus-pane-up") } | |
| 159 | + | func (s *controlSpy) FocusPaneDown() error { return s.record("focus-pane-down") } | |
| 160 | + | func (s *controlSpy) NextTab() error { return s.record("next-tab") } | |
| 161 | + | func (s *controlSpy) PrevTab() error { return s.record("previous-tab") } | |
| 162 | + | func (s *controlSpy) RenameSessionTo(v string) error { return s.record("rename-session:" + v) } | |
| 163 | + | func (s *controlSpy) RenameTabTo(v string) error { return s.record("rename-tab:" + v) } | |
| 164 | + | func (s *controlSpy) Toast(level, message string) error { | |
| 165 | + | return s.record("toast:" + level + ":" + message) | |
| 166 | + | } | |
| 167 | + | func (s *controlSpy) ListSessions(json bool) (string, error) { | |
| 168 | + | _ = s.record("list-sessions:" + strconv.FormatBool(json)) | |
| 169 | + | return "sessions", nil | |
| 170 | + | } | |
| 171 | + | func (s *controlSpy) ListTabs(json bool) (string, error) { | |
| 172 | + | _ = s.record("list-tabs:" + strconv.FormatBool(json)) | |
| 173 | + | return "tabs", nil | |
| 174 | + | } | |
| 175 | + | func (s *controlSpy) ListPanes(json bool) (string, error) { | |
| 176 | + | _ = s.record("list-panes:" + strconv.FormatBool(json)) | |
| 177 | + | return "panes", nil | |
| 178 | + | } | |
| 179 | + | ||
| 40 | 180 | func TestRegistryIncludesFloatingPaneToggle(t *testing.T) { | |
| 41 | 181 | cmd, ok := ByCode("FLT") | |
| 42 | 182 | if !ok { | |
| @@ -1,12 +1,14 @@ | |||||
| 1 | package command | 1 | package command | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "errors" | |||
| 4 | "regexp" | 5 | "regexp" | ||
| 6 | + | "strconv" | |||
| 5 | "testing" | 7 | "testing" | ||
| 6 | ) | 8 | ) | ||
| 7 | 9 | ||||
| 8 | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | 10 | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | ||
| 9 | - | commands := Registry() | 11 | + | commands := PaletteRegistry() |
| 10 | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"} | 12 | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"} | ||
| 11 | 13 | ||||
| 12 | if len(commands) != len(wantCodes) { | 14 | if len(commands) != len(wantCodes) { | ||
| @@ -37,6 +39,144 @@ func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | |||||
| 37 | } | 39 | } | ||
| 38 | } | 40 | } | ||
| 39 | 41 | ||||
| 42 | + | func TestBySlugExactMatch(t *testing.T) { | |||
| 43 | + | tests := []struct { | |||
| 44 | + | slug string | |||
| 45 | + | ok bool | |||
| 46 | + | }{ | |||
| 47 | + | {slug: "split-right", ok: true}, | |||
| 48 | + | {slug: "SPLIT-RIGHT", ok: false}, | |||
| 49 | + | {slug: "toast", ok: true}, | |||
| 50 | + | {slug: "list-panes", ok: true}, | |||
| 51 | + | {slug: "does-not-exist", ok: false}, | |||
| 52 | + | } | |||
| 53 | + | for _, tt := range tests { | |||
| 54 | + | t.Run(tt.slug, func(t *testing.T) { | |||
| 55 | + | if _, ok := BySlug(tt.slug); ok != tt.ok { | |||
| 56 | + | t.Fatalf("BySlug(%q) ok = %v, want %v", tt.slug, ok, tt.ok) | |||
| 57 | + | } | |||
| 58 | + | }) | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestRegistryControlMetadata(t *testing.T) { | |||
| 63 | + | scriptable := map[string]TargetKind{ | |||
| 64 | + | "split-right": TargetPane, "split-left": TargetPane, "split-up": TargetPane, | |||
| 65 | + | "split-down": TargetPane, "stack-pane": TargetPane, "toggle-stack": TargetPane, | |||
| 66 | + | "close-pane": TargetPane, "focus-pane-left": TargetPane, "focus-pane-right": TargetPane, | |||
| 67 | + | "focus-pane-up": TargetPane, "focus-pane-down": TargetPane, | |||
| 68 | + | "new-tab": TargetSession, "close-tab": TargetTab, "next-tab": TargetSession, | |||
| 69 | + | "previous-tab": TargetSession, "rename-session": TargetSession, "rename-tab": TargetTab, | |||
| 70 | + | "new-session": TargetSession, "toast": TargetSession, | |||
| 71 | + | "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab, | |||
| 72 | + | } | |||
| 73 | + | notScriptable := []string{ | |||
| 74 | + | "session-picker", "notifications", "visual-mode", "yank-last-notification", | |||
| 75 | + | "detach", "back-session", "jump-recent-session", "toggle-floating-pane", | |||
| 76 | + | } | |||
| 77 | + | for slug, target := range scriptable { | |||
| 78 | + | cmd, ok := BySlug(slug) | |||
| 79 | + | if !ok || !cmd.Scriptable { | |||
| 80 | + | t.Errorf("%s: want scriptable entry, ok=%v scriptable=%v", slug, ok, cmd.Scriptable) | |||
| 81 | + | continue | |||
| 82 | + | } | |||
| 83 | + | if cmd.Target != target || cmd.Control == nil || cmd.Usage == "" || cmd.Desc == "" { | |||
| 84 | + | t.Errorf("%s: incomplete control metadata: %#v", slug, cmd) | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | for _, slug := range notScriptable { | |||
| 88 | + | cmd, ok := BySlug(slug) | |||
| 89 | + | if !ok || cmd.Scriptable || cmd.Control != nil { | |||
| 90 | + | t.Errorf("%s: want present and non-scriptable, got %#v, ok=%v", slug, cmd, ok) | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func TestPaletteRegistryHidesAPIOnly(t *testing.T) { | |||
| 96 | + | for _, cmd := range PaletteRegistry() { | |||
| 97 | + | if !cmd.PaletteVisible || cmd.Run == nil { | |||
| 98 | + | t.Errorf("palette command is not executable and visible: %#v", cmd) | |||
| 99 | + | } | |||
| 100 | + | if cmd.Slug == "toast" || cmd.Slug == "list-sessions" || cmd.Slug == "list-tabs" || cmd.Slug == "list-panes" { | |||
| 101 | + | t.Errorf("API-only command %q is visible", cmd.Slug) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestControlHandlersValidateAndDelegate(t *testing.T) { | |||
| 107 | + | tests := []struct { | |||
| 108 | + | name string | |||
| 109 | + | slug string | |||
| 110 | + | args []string | |||
| 111 | + | opts ControlOptions | |||
| 112 | + | wantCall string | |||
| 113 | + | wantOutput string | |||
| 114 | + | wantErr error | |||
| 115 | + | }{ | |||
| 116 | + | {name: "zero argument mutation delegates", slug: "split-right", wantCall: "split-right"}, | |||
| 117 | + | {name: "zero argument mutation rejects args", slug: "split-right", args: []string{"extra"}, wantErr: ErrInvalidArguments}, | |||
| 118 | + | {name: "rename delegates name", slug: "rename-tab", args: []string{"editor"}, wantCall: "rename-tab:editor"}, | |||
| 119 | + | {name: "rename rejects empty name", slug: "rename-tab", args: []string{""}, wantErr: ErrInvalidArguments}, | |||
| 120 | + | {name: "toast delegates severity", slug: "toast", args: []string{"-l", "warn", "hello"}, wantCall: "toast:warn:hello"}, | |||
| 121 | + | {name: "toast rejects malformed flags", slug: "toast", args: []string{"-l", "warn"}, wantErr: ErrInvalidArguments}, | |||
| 122 | + | {name: "query delegates JSON option", slug: "list-panes", opts: ControlOptions{JSON: true}, wantCall: "list-panes:true", wantOutput: "panes"}, | |||
| 123 | + | {name: "query rejects args", slug: "list-panes", args: []string{"extra"}, wantErr: ErrInvalidArguments}, | |||
| 124 | + | } | |||
| 125 | + | for _, tt := range tests { | |||
| 126 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 127 | + | cmd, ok := BySlug(tt.slug) | |||
| 128 | + | if !ok { | |||
| 129 | + | t.Fatalf("BySlug(%q) missing", tt.slug) | |||
| 130 | + | } | |||
| 131 | + | ctx := &controlSpy{} | |||
| 132 | + | got, err := cmd.Control(ctx, tt.args, tt.opts) | |||
| 133 | + | if !errors.Is(err, tt.wantErr) { | |||
| 134 | + | t.Fatalf("Control() error = %v, want %v", err, tt.wantErr) | |||
| 135 | + | } | |||
| 136 | + | if ctx.call != tt.wantCall || got.Output != tt.wantOutput { | |||
| 137 | + | t.Fatalf("Control() call/output = %q/%q, want %q/%q", ctx.call, got.Output, tt.wantCall, tt.wantOutput) | |||
| 138 | + | } | |||
| 139 | + | }) | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | type controlSpy struct{ call string } | |||
| 144 | + | ||||
| 145 | + | func (s *controlSpy) record(call string) error { s.call = call; return nil } | |||
| 146 | + | func (s *controlSpy) CreateTab() error { return s.record("new-tab") } | |||
| 147 | + | func (s *controlSpy) CreateSessionNamed(v string) error { return s.record("new-session:" + v) } | |||
| 148 | + | func (s *controlSpy) CloseTab() error { return s.record("close-tab") } | |||
| 149 | + | func (s *controlSpy) ClosePane() error { return s.record("close-pane") } | |||
| 150 | + | func (s *controlSpy) SplitRight() error { return s.record("split-right") } | |||
| 151 | + | func (s *controlSpy) SplitLeft() error { return s.record("split-left") } | |||
| 152 | + | func (s *controlSpy) SplitUp() error { return s.record("split-up") } | |||
| 153 | + | func (s *controlSpy) SplitDown() error { return s.record("split-down") } | |||
| 154 | + | func (s *controlSpy) StackPane() error { return s.record("stack-pane") } | |||
| 155 | + | func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") } | |||
| 156 | + | func (s *controlSpy) FocusPaneLeft() error { return s.record("focus-pane-left") } | |||
| 157 | + | func (s *controlSpy) FocusPaneRight() error { return s.record("focus-pane-right") } | |||
| 158 | + | func (s *controlSpy) FocusPaneUp() error { return s.record("focus-pane-up") } | |||
| 159 | + | func (s *controlSpy) FocusPaneDown() error { return s.record("focus-pane-down") } | |||
| 160 | + | func (s *controlSpy) NextTab() error { return s.record("next-tab") } | |||
| 161 | + | func (s *controlSpy) PrevTab() error { return s.record("previous-tab") } | |||
| 162 | + | func (s *controlSpy) RenameSessionTo(v string) error { return s.record("rename-session:" + v) } | |||
| 163 | + | func (s *controlSpy) RenameTabTo(v string) error { return s.record("rename-tab:" + v) } | |||
| 164 | + | func (s *controlSpy) Toast(level, message string) error { | |||
| 165 | + | return s.record("toast:" + level + ":" + message) | |||
| 166 | + | } | |||
| 167 | + | func (s *controlSpy) ListSessions(json bool) (string, error) { | |||
| 168 | + | _ = s.record("list-sessions:" + strconv.FormatBool(json)) | |||
| 169 | + | return "sessions", nil | |||
| 170 | + | } | |||
| 171 | + | func (s *controlSpy) ListTabs(json bool) (string, error) { | |||
| 172 | + | _ = s.record("list-tabs:" + strconv.FormatBool(json)) | |||
| 173 | + | return "tabs", nil | |||
| 174 | + | } | |||
| 175 | + | func (s *controlSpy) ListPanes(json bool) (string, error) { | |||
| 176 | + | _ = s.record("list-panes:" + strconv.FormatBool(json)) | |||
| 177 | + | return "panes", nil | |||
| 178 | + | } | |||
| 179 | + | ||||
| 40 | func TestRegistryIncludesFloatingPaneToggle(t *testing.T) { | 180 | func TestRegistryIncludesFloatingPaneToggle(t *testing.T) { | ||
| 41 | cmd, ok := ByCode("FLT") | 181 | cmd, ok := ByCode("FLT") | ||
| 42 | if !ok { | 182 | if !ok { | ||
M internal/usecase/daemon/attention_test.go +2 -2
| @@ -326,7 +326,7 @@ func TestCloseRingingTabClearsClientBar(t *testing.T) { | |||
| 326 | 326 | before := mustOutputData(t, sends) | |
| 327 | 327 | require.Contains(t, string(before), "") | |
| 328 | 328 | ||
| 329 | - | d.closeTab(sess, ringing, true) | |
| 329 | + | require.NoError(t, d.closeTab(sess, ringing, true)) | |
| 330 | 330 | after := mustOutputData(t, sends) | |
| 331 | 331 | require.NotContains(t, string(after), "") | |
| 332 | 332 | } | |
| @@ -455,7 +455,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) { | |||
| 455 | 455 | before := mustOutputData(t, sendsB) | |
| 456 | 456 | require.Contains(t, string(before), string(ui.AttentionGlyph)) | |
| 457 | 457 | ||
| 458 | - | d.closeTab(sessA, ringing, true) | |
| 458 | + | require.NoError(t, d.closeTab(sessA, ringing, true)) | |
| 459 | 459 | _ = mustOutputData(t, sendsA) | |
| 460 | 460 | ||
| 461 | 461 | after := mustOutputData(t, sendsB) | |
| @@ -326,7 +326,7 @@ func TestCloseRingingTabClearsClientBar(t *testing.T) { | |||||
| 326 | before := mustOutputData(t, sends) | 326 | before := mustOutputData(t, sends) | ||
| 327 | require.Contains(t, string(before), "") | 327 | require.Contains(t, string(before), "") | ||
| 328 | 328 | ||||
| 329 | - | d.closeTab(sess, ringing, true) | 329 | + | require.NoError(t, d.closeTab(sess, ringing, true)) |
| 330 | after := mustOutputData(t, sends) | 330 | after := mustOutputData(t, sends) | ||
| 331 | require.NotContains(t, string(after), "") | 331 | require.NotContains(t, string(after), "") | ||
| 332 | } | 332 | } | ||
| @@ -455,7 +455,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) { | |||||
| 455 | before := mustOutputData(t, sendsB) | 455 | before := mustOutputData(t, sendsB) | ||
| 456 | require.Contains(t, string(before), string(ui.AttentionGlyph)) | 456 | require.Contains(t, string(before), string(ui.AttentionGlyph)) | ||
| 457 | 457 | ||||
| 458 | - | d.closeTab(sessA, ringing, true) | 458 | + | require.NoError(t, d.closeTab(sessA, ringing, true)) |
| 459 | _ = mustOutputData(t, sendsA) | 459 | _ = mustOutputData(t, sendsA) | ||
| 460 | 460 | ||||
| 461 | after := mustOutputData(t, sendsB) | 461 | after := mustOutputData(t, sendsB) | ||
M internal/usecase/daemon/bar_script_test.go +5 -5
| @@ -119,7 +119,7 @@ func TestBarScriptRunner(t *testing.T) { | |||
| 119 | 119 | containsEnv(spec.Env, "VEV_PANE_CWD=/repo") && | |
| 120 | 120 | containsEnv(spec.Env, "VEV_COLS=120") && | |
| 121 | 121 | !containsEnv(spec.Env, "VEV_ANCHOR=old") | |
| 122 | - | })).Return(ports.CommandResult{Stdout: []byte("\x1b[31mok\x1b[0m\nignored")}, nil) | |
| 122 | + | })).Return(ports.ShellCommandResult{Stdout: []byte("\x1b[31mok\x1b[0m\nignored")}, nil) | |
| 123 | 123 | runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond} | |
| 124 | 124 | ||
| 125 | 125 | got, err := runner.run(context.Background(), "printf 'ok\\nignored'", | |
| @@ -137,7 +137,7 @@ func TestBarScriptRunner(t *testing.T) { | |||
| 137 | 137 | wantErr := errors.New("exit 1") | |
| 138 | 138 | portRunner := portsmocks.NewMockShellCommandRunner(t) | |
| 139 | 139 | portRunner.EXPECT().Run(mock.Anything, mock.Anything). | |
| 140 | - | Return(ports.CommandResult{Stdout: []byte("partial\x00 output\nignored"), ExitCode: 1}, wantErr) | |
| 140 | + | Return(ports.ShellCommandResult{Stdout: []byte("partial\x00 output\nignored"), ExitCode: 1}, wantErr) | |
| 141 | 141 | runner := barScriptRunner{runner: portRunner, timeout: time.Second} | |
| 142 | 142 | ||
| 143 | 143 | got, err := runner.run(context.Background(), "false", nil, barScriptContext{}) | |
| @@ -152,9 +152,9 @@ func TestBarScriptRunner(t *testing.T) { | |||
| 152 | 152 | t.Run("timeout propagates deadline", func(t *testing.T) { | |
| 153 | 153 | portRunner := portsmocks.NewMockShellCommandRunner(t) | |
| 154 | 154 | portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn( | |
| 155 | - | func(ctx context.Context, _ ports.CommandSpec) (ports.CommandResult, error) { | |
| 155 | + | func(ctx context.Context, _ ports.CommandSpec) (ports.ShellCommandResult, error) { | |
| 156 | 156 | <-ctx.Done() | |
| 157 | - | return ports.CommandResult{ExitCode: -1}, ctx.Err() | |
| 157 | + | return ports.ShellCommandResult{ExitCode: -1}, ctx.Err() | |
| 158 | 158 | }) | |
| 159 | 159 | runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond} | |
| 160 | 160 | ||
| @@ -169,7 +169,7 @@ func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) { | |||
| 169 | 169 | portRunner := portsmocks.NewMockShellCommandRunner(t) | |
| 170 | 170 | portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool { | |
| 171 | 171 | return spec.StdoutLimit == barScriptOutputLimit | |
| 172 | - | })).Return(ports.CommandResult{Stdout: []byte(strings.Repeat("a", 4096))}, nil) | |
| 172 | + | })).Return(ports.ShellCommandResult{Stdout: []byte(strings.Repeat("a", 4096))}, nil) | |
| 173 | 173 | runner := barScriptRunner{runner: portRunner, timeout: time.Second} | |
| 174 | 174 | ||
| 175 | 175 | got, err := runner.run(context.Background(), "long-output", nil, barScriptContext{}) | |
| @@ -119,7 +119,7 @@ func TestBarScriptRunner(t *testing.T) { | |||||
| 119 | containsEnv(spec.Env, "VEV_PANE_CWD=/repo") && | 119 | containsEnv(spec.Env, "VEV_PANE_CWD=/repo") && | ||
| 120 | containsEnv(spec.Env, "VEV_COLS=120") && | 120 | containsEnv(spec.Env, "VEV_COLS=120") && | ||
| 121 | !containsEnv(spec.Env, "VEV_ANCHOR=old") | 121 | !containsEnv(spec.Env, "VEV_ANCHOR=old") | ||
| 122 | - | })).Return(ports.CommandResult{Stdout: []byte("\x1b[31mok\x1b[0m\nignored")}, nil) | 122 | + | })).Return(ports.ShellCommandResult{Stdout: []byte("\x1b[31mok\x1b[0m\nignored")}, nil) |
| 123 | runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond} | 123 | runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond} | ||
| 124 | 124 | ||||
| 125 | got, err := runner.run(context.Background(), "printf 'ok\\nignored'", | 125 | got, err := runner.run(context.Background(), "printf 'ok\\nignored'", | ||
| @@ -137,7 +137,7 @@ func TestBarScriptRunner(t *testing.T) { | |||||
| 137 | wantErr := errors.New("exit 1") | 137 | wantErr := errors.New("exit 1") | ||
| 138 | portRunner := portsmocks.NewMockShellCommandRunner(t) | 138 | portRunner := portsmocks.NewMockShellCommandRunner(t) | ||
| 139 | portRunner.EXPECT().Run(mock.Anything, mock.Anything). | 139 | portRunner.EXPECT().Run(mock.Anything, mock.Anything). | ||
| 140 | - | Return(ports.CommandResult{Stdout: []byte("partial\x00 output\nignored"), ExitCode: 1}, wantErr) | 140 | + | Return(ports.ShellCommandResult{Stdout: []byte("partial\x00 output\nignored"), ExitCode: 1}, wantErr) |
| 141 | runner := barScriptRunner{runner: portRunner, timeout: time.Second} | 141 | runner := barScriptRunner{runner: portRunner, timeout: time.Second} | ||
| 142 | 142 | ||||
| 143 | got, err := runner.run(context.Background(), "false", nil, barScriptContext{}) | 143 | got, err := runner.run(context.Background(), "false", nil, barScriptContext{}) | ||
| @@ -152,9 +152,9 @@ func TestBarScriptRunner(t *testing.T) { | |||||
| 152 | t.Run("timeout propagates deadline", func(t *testing.T) { | 152 | t.Run("timeout propagates deadline", func(t *testing.T) { | ||
| 153 | portRunner := portsmocks.NewMockShellCommandRunner(t) | 153 | portRunner := portsmocks.NewMockShellCommandRunner(t) | ||
| 154 | portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn( | 154 | portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn( | ||
| 155 | - | func(ctx context.Context, _ ports.CommandSpec) (ports.CommandResult, error) { | 155 | + | func(ctx context.Context, _ ports.CommandSpec) (ports.ShellCommandResult, error) { |
| 156 | <-ctx.Done() | 156 | <-ctx.Done() | ||
| 157 | - | return ports.CommandResult{ExitCode: -1}, ctx.Err() | 157 | + | return ports.ShellCommandResult{ExitCode: -1}, ctx.Err() |
| 158 | }) | 158 | }) | ||
| 159 | runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond} | 159 | runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond} | ||
| 160 | 160 | ||||
| @@ -169,7 +169,7 @@ func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) { | |||||
| 169 | portRunner := portsmocks.NewMockShellCommandRunner(t) | 169 | portRunner := portsmocks.NewMockShellCommandRunner(t) | ||
| 170 | portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool { | 170 | portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool { | ||
| 171 | return spec.StdoutLimit == barScriptOutputLimit | 171 | return spec.StdoutLimit == barScriptOutputLimit | ||
| 172 | - | })).Return(ports.CommandResult{Stdout: []byte(strings.Repeat("a", 4096))}, nil) | 172 | + | })).Return(ports.ShellCommandResult{Stdout: []byte(strings.Repeat("a", 4096))}, nil) |
| 173 | runner := barScriptRunner{runner: portRunner, timeout: time.Second} | 173 | runner := barScriptRunner{runner: portRunner, timeout: time.Second} | ||
| 174 | 174 | ||||
| 175 | got, err := runner.run(context.Background(), "long-output", nil, barScriptContext{}) | 175 | got, err := runner.run(context.Background(), "long-output", nil, barScriptContext{}) | ||
M internal/usecase/daemon/config.go +2 -2
| @@ -113,7 +113,7 @@ func configWarningsError(warnings []domain.Warning) error { | |||
| 113 | 113 | } | |
| 114 | 114 | ||
| 115 | 115 | func (d *Daemon) buildCodeOverrides(configured map[string]string) (map[string]string, []domain.Warning) { | |
| 116 | - | commands := command.Registry() | |
| 116 | + | commands := command.PaletteRegistry() | |
| 117 | 117 | bySlug := make(map[string]command.Command, len(commands)) | |
| 118 | 118 | for _, cmd := range commands { | |
| 119 | 119 | bySlug[cmd.Slug] = cmd | |
| @@ -294,7 +294,7 @@ func commandWithOverrides(cmd command.Command, overrides map[string]string) comm | |||
| 294 | 294 | func (d *Daemon) commandByEffectiveCode(code string) (command.Command, bool) { | |
| 295 | 295 | code = strings.ToUpper(strings.TrimSpace(code)) | |
| 296 | 296 | overrides := d.codeOverrideSnapshot() | |
| 297 | - | for _, cmd := range command.Registry() { | |
| 297 | + | for _, cmd := range command.PaletteRegistry() { | |
| 298 | 298 | cmd = commandWithOverrides(cmd, overrides) | |
| 299 | 299 | if cmd.Code == code { | |
| 300 | 300 | return cmd, true | |
| @@ -113,7 +113,7 @@ func configWarningsError(warnings []domain.Warning) error { | |||||
| 113 | } | 113 | } | ||
| 114 | 114 | ||||
| 115 | func (d *Daemon) buildCodeOverrides(configured map[string]string) (map[string]string, []domain.Warning) { | 115 | func (d *Daemon) buildCodeOverrides(configured map[string]string) (map[string]string, []domain.Warning) { | ||
| 116 | - | commands := command.Registry() | 116 | + | commands := command.PaletteRegistry() |
| 117 | bySlug := make(map[string]command.Command, len(commands)) | 117 | bySlug := make(map[string]command.Command, len(commands)) | ||
| 118 | for _, cmd := range commands { | 118 | for _, cmd := range commands { | ||
| 119 | bySlug[cmd.Slug] = cmd | 119 | bySlug[cmd.Slug] = cmd | ||
| @@ -294,7 +294,7 @@ func commandWithOverrides(cmd command.Command, overrides map[string]string) comm | |||||
| 294 | func (d *Daemon) commandByEffectiveCode(code string) (command.Command, bool) { | 294 | func (d *Daemon) commandByEffectiveCode(code string) (command.Command, bool) { | ||
| 295 | code = strings.ToUpper(strings.TrimSpace(code)) | 295 | code = strings.ToUpper(strings.TrimSpace(code)) | ||
| 296 | overrides := d.codeOverrideSnapshot() | 296 | overrides := d.codeOverrideSnapshot() | ||
| 297 | - | for _, cmd := range command.Registry() { | 297 | + | for _, cmd := range command.PaletteRegistry() { |
| 298 | cmd = commandWithOverrides(cmd, overrides) | 298 | cmd = commandWithOverrides(cmd, overrides) | ||
| 299 | if cmd.Code == code { | 299 | if cmd.Code == code { | ||
| 300 | return cmd, true | 300 | return cmd, true | ||
A internal/usecase/daemon/control.go +574 -0
| @@ -0,0 +1,574 @@ | |||
| 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 && !request.Self { | |
| 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) | |
| 57 | + | if code != 0 { | |
| 58 | + | return commandFailure(code, text) | |
| 59 | + | } | |
| 60 | + | return d.runControl(cmd, controlExec{d: d, sess: sess, tab: tb, target: daemonActionTarget{session: 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), errors.Is(err, domain.ErrInvalidSessionName): | |
| 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.Self && (request.TargetTab == "" || request.TargetPane == "") { | |
| 91 | + | return nil, ports.ErrNoSuchTarget, "--self requires target tab and pane IDs" | |
| 92 | + | } | |
| 93 | + | if request.TargetSession != "" && request.TargetTab == "" && request.TargetPane == "" { | |
| 94 | + | if named == nil { | |
| 95 | + | return nil, ports.ErrNoSuchTarget, "no such session: " + request.TargetSession | |
| 96 | + | } | |
| 97 | + | return named, 0, "" | |
| 98 | + | } | |
| 99 | + | if request.TargetTab != "" || request.TargetPane != "" { | |
| 100 | + | if request.TargetTab == "" || request.TargetPane == "" { | |
| 101 | + | return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together" | |
| 102 | + | } | |
| 103 | + | for _, sess := range d.sessions { | |
| 104 | + | if sess.containsStableIDs(request.TargetTab, request.TargetPane) { | |
| 105 | + | if named != nil && named != sess { | |
| 106 | + | return nil, ports.ErrNoSuchTarget, "tab/pane IDs belong to another session" | |
| 107 | + | } | |
| 108 | + | return sess, 0, "" | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | return nil, ports.ErrNoSuchTarget, "no live session contains the target tab/pane" | |
| 112 | + | } | |
| 113 | + | if len(d.sessions) == 0 { | |
| 114 | + | return nil, ports.ErrNoSuchTarget, "no live sessions" | |
| 115 | + | } | |
| 116 | + | if len(d.sessions) != 1 { | |
| 117 | + | return nil, ports.ErrAmbiguousTarget, "several sessions are live; use -s <session> or run from inside a pane" | |
| 118 | + | } | |
| 119 | + | for _, sess := range d.sessions { | |
| 120 | + | return sess, 0, "" | |
| 121 | + | } | |
| 122 | + | panic("unreachable") | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func (s *session) containsStableIDs(tabID, paneID string) bool { | |
| 126 | + | s.mu.Lock() | |
| 127 | + | tabs := append([]*tab(nil), s.tabs...) | |
| 128 | + | s.mu.Unlock() | |
| 129 | + | for _, tb := range tabs { | |
| 130 | + | tb.mu.Lock() | |
| 131 | + | pane := paneByStableIDLocked(tb, paneID) | |
| 132 | + | match := tb.stableID == tabID && pane != nil | |
| 133 | + | tb.mu.Unlock() | |
| 134 | + | if match { | |
| 135 | + | return true | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | return false | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func resolveControlTarget(sess *session, kind command.TargetKind, request ports.CommandRequest) (*tab, *pane, uint16, string) { | |
| 142 | + | sess.mu.Lock() | |
| 143 | + | tabs := append([]*tab(nil), sess.tabs...) | |
| 144 | + | active := sess.active | |
| 145 | + | sess.mu.Unlock() | |
| 146 | + | if request.Self { | |
| 147 | + | for _, tb := range tabs { | |
| 148 | + | tb.mu.Lock() | |
| 149 | + | if tb.stableID != request.TargetTab { | |
| 150 | + | tb.mu.Unlock() | |
| 151 | + | continue | |
| 152 | + | } | |
| 153 | + | target := paneByStableIDLocked(tb, request.TargetPane) | |
| 154 | + | tb.mu.Unlock() | |
| 155 | + | if target == nil { | |
| 156 | + | return nil, nil, ports.ErrNoSuchTarget, "target pane does not belong to target tab" | |
| 157 | + | } | |
| 158 | + | return tb, target, 0, "" | |
| 159 | + | } | |
| 160 | + | return nil, nil, ports.ErrNoSuchTarget, "no such target tab" | |
| 161 | + | } | |
| 162 | + | if len(tabs) == 0 || active < 0 || active >= len(tabs) { | |
| 163 | + | return nil, nil, ports.ErrNoSuchTarget, "target session has no active tab" | |
| 164 | + | } | |
| 165 | + | tb := tabs[active] | |
| 166 | + | if kind != command.TargetPane { | |
| 167 | + | return tb, nil, 0, "" | |
| 168 | + | } | |
| 169 | + | tb.mu.Lock() | |
| 170 | + | target := tb.focusedPane() | |
| 171 | + | tb.mu.Unlock() | |
| 172 | + | if target == nil { | |
| 173 | + | return nil, nil, ports.ErrNoSuchTarget, "no such target pane" | |
| 174 | + | } | |
| 175 | + | return tb, target, 0, "" | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func paneByStableIDLocked(tb *tab, stableID string) *pane { | |
| 179 | + | for _, pane := range tb.panes { | |
| 180 | + | if pane.stableID == stableID { | |
| 181 | + | return pane | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | return nil | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | type daemonActionKind uint8 | |
| 188 | + | ||
| 189 | + | const ( | |
| 190 | + | daemonActionCreateTab daemonActionKind = iota | |
| 191 | + | daemonActionCloseTab | |
| 192 | + | daemonActionSplitPane | |
| 193 | + | daemonActionStackPane | |
| 194 | + | daemonActionToggleStack | |
| 195 | + | daemonActionClosePane | |
| 196 | + | daemonActionFocusPane | |
| 197 | + | daemonActionNextTab | |
| 198 | + | daemonActionPreviousTab | |
| 199 | + | daemonActionRenameSession | |
| 200 | + | daemonActionRenameTab | |
| 201 | + | ) | |
| 202 | + | ||
| 203 | + | type daemonActionTarget struct { | |
| 204 | + | session *session | |
| 205 | + | tab *tab | |
| 206 | + | pane *pane | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | type daemonActionRequest struct { | |
| 210 | + | kind daemonActionKind | |
| 211 | + | target daemonActionTarget | |
| 212 | + | direction layout.Direction | |
| 213 | + | viewport domain.Size | |
| 214 | + | name string | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | type daemonActionRunner interface { | |
| 218 | + | Run(daemonActionRequest) error | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func resolveDaemonActionTarget(sess *session) daemonActionTarget { | |
| 222 | + | if sess == nil { | |
| 223 | + | return daemonActionTarget{} | |
| 224 | + | } | |
| 225 | + | tb := sess.activeTab() | |
| 226 | + | if tb == nil { | |
| 227 | + | return daemonActionTarget{session: sess} | |
| 228 | + | } | |
| 229 | + | tb.mu.Lock() | |
| 230 | + | pane := tb.focusedPane() | |
| 231 | + | tb.mu.Unlock() | |
| 232 | + | return daemonActionTarget{session: sess, tab: tb, pane: pane} | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // daemonActions is the daemon-owned mutation seam shared by palette and | |
| 236 | + | // control. Requests contain already-resolved targets and no attachment state; | |
| 237 | + | // adapters remain responsible for their attached-client UI lifecycle. | |
| 238 | + | type daemonActions struct{ d *Daemon } | |
| 239 | + | ||
| 240 | + | func (a daemonActions) Run(request daemonActionRequest) error { | |
| 241 | + | target := request.target | |
| 242 | + | switch request.kind { | |
| 243 | + | case daemonActionCreateTab: | |
| 244 | + | return a.d.createTab(target.session, request.viewport) | |
| 245 | + | case daemonActionCloseTab: | |
| 246 | + | return a.d.closeTab(target.session, target.tab, true) | |
| 247 | + | case daemonActionSplitPane: | |
| 248 | + | return a.d.splitPaneAt(target.session, target.tab, target.pane, request.direction) | |
| 249 | + | case daemonActionStackPane: | |
| 250 | + | return a.d.stackPaneAt(target.session, target.tab, target.pane) | |
| 251 | + | case daemonActionToggleStack: | |
| 252 | + | return a.d.toggleStackAt(target.session, target.tab, target.pane) | |
| 253 | + | case daemonActionClosePane: | |
| 254 | + | if a.d.ptys == nil { | |
| 255 | + | return nil | |
| 256 | + | } | |
| 257 | + | if !a.d.hasDaemonActionPaneTarget(target) { | |
| 258 | + | return layout.ErrNotFound | |
| 259 | + | } | |
| 260 | + | if err := a.d.closePane(target.session, target.tab, target.pane.id, nil, true); err != nil { | |
| 261 | + | return err | |
| 262 | + | } | |
| 263 | + | if a.d.hasDaemonActionPaneTarget(target) { | |
| 264 | + | return errors.New("pane close did not complete") | |
| 265 | + | } | |
| 266 | + | return nil | |
| 267 | + | case daemonActionFocusPane: | |
| 268 | + | return a.d.focusDirAt(target.session, target.tab, target.pane, request.direction) | |
| 269 | + | case daemonActionNextTab: | |
| 270 | + | return a.switchRelative(target.session, 1) | |
| 271 | + | case daemonActionPreviousTab: | |
| 272 | + | return a.switchRelative(target.session, -1) | |
| 273 | + | case daemonActionRenameSession: | |
| 274 | + | return a.d.renameSession(target.session, request.name) | |
| 275 | + | case daemonActionRenameTab: | |
| 276 | + | return a.d.renameTab(target.session, target.tab, request.name) | |
| 277 | + | default: | |
| 278 | + | return errors.New("daemon: unknown action") | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | // hasDaemonActionPaneTarget verifies that an already-resolved pane still | |
| 283 | + | // belongs to the live tab and session. Close-pane uses it both before mutation | |
| 284 | + | // and as a postcondition because closing a final pane delegates to close-tab. | |
| 285 | + | func (d *Daemon) hasDaemonActionPaneTarget(target daemonActionTarget) bool { | |
| 286 | + | if target.session == nil || target.tab == nil || target.pane == nil { | |
| 287 | + | return false | |
| 288 | + | } | |
| 289 | + | d.mu.Lock() | |
| 290 | + | defer d.mu.Unlock() | |
| 291 | + | if d.sessions[target.session.id] != target.session { | |
| 292 | + | return false | |
| 293 | + | } | |
| 294 | + | target.session.mu.Lock() | |
| 295 | + | defer target.session.mu.Unlock() | |
| 296 | + | foundTab := false | |
| 297 | + | for _, tb := range target.session.tabs { | |
| 298 | + | if tb == target.tab { | |
| 299 | + | foundTab = true | |
| 300 | + | break | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | if !foundTab { | |
| 304 | + | return false | |
| 305 | + | } | |
| 306 | + | target.tab.mu.Lock() | |
| 307 | + | defer target.tab.mu.Unlock() | |
| 308 | + | return target.tab.panes[target.pane.id] == target.pane && | |
| 309 | + | target.tab.tree != nil && layout.ContainsLeaf(target.tab.tree.Root, target.pane.id) | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | func (a daemonActions) switchRelative(sess *session, delta int) error { | |
| 313 | + | if sess.switchRelative(delta) { | |
| 314 | + | a.d.activateTab(sess, sess.activeTab()) | |
| 315 | + | } | |
| 316 | + | return nil | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | // controlExec implements command.ControlContext against resolved daemon-owned | |
| 320 | + | // targets. It deliberately contains no attached-client state. | |
| 321 | + | type controlExec struct { | |
| 322 | + | d *Daemon | |
| 323 | + | sess *session | |
| 324 | + | tab *tab | |
| 325 | + | target daemonActionTarget | |
| 326 | + | actions daemonActionRunner | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | func (e controlExec) runAction(request daemonActionRequest) error { | |
| 330 | + | if request.target.session == nil { | |
| 331 | + | request.target = e.target | |
| 332 | + | } | |
| 333 | + | runner := e.actions | |
| 334 | + | if runner == nil { | |
| 335 | + | runner = daemonActions{d: e.d} | |
| 336 | + | } | |
| 337 | + | err := runner.Run(request) | |
| 338 | + | if err == nil && e.actions == nil { | |
| 339 | + | e.sess.mu.Lock() | |
| 340 | + | ac := e.sess.client | |
| 341 | + | e.sess.mu.Unlock() | |
| 342 | + | finishDaemonActionForClient(e.d, request, ac, "control.go") | |
| 343 | + | } | |
| 344 | + | return err | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *attachedClient, producer string) { | |
| 348 | + | if ac == nil { | |
| 349 | + | return | |
| 350 | + | } | |
| 351 | + | if request.kind == daemonActionFocusPane && request.target.tab != nil && request.target.pane != nil { | |
| 352 | + | tb := request.target.tab | |
| 353 | + | tb.mu.Lock() | |
| 354 | + | newFocus := tb.tree.Focus | |
| 355 | + | pl, hasPlacement := focusedPlacementLocked(tb) | |
| 356 | + | tb.mu.Unlock() | |
| 357 | + | if newFocus != request.target.pane.id { | |
| 358 | + | d.exitCopyMode(ac) | |
| 359 | + | if hasPlacement && pl.TitleBar.Height > 0 { | |
| 360 | + | d.refreshPaneTitleOnFocus(request.target.session, newFocus) | |
| 361 | + | } | |
| 362 | + | } | |
| 363 | + | } | |
| 364 | + | d.invalidateRender(request.target.session, ac, true, producer) | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | func (e controlExec) CreateTab() error { | |
| 368 | + | return e.runAction(daemonActionRequest{kind: daemonActionCreateTab, viewport: e.sess.fullViewportSize()}) | |
| 369 | + | } | |
| 370 | + | func (e controlExec) CreateSessionNamed(name string) error { | |
| 371 | + | if err := domain.ValidateSessionName(name); err != nil { | |
| 372 | + | return command.ErrInvalidArguments | |
| 373 | + | } | |
| 374 | + | e.sess.mu.Lock() | |
| 375 | + | cwd, term, env := e.sess.cwd, e.sess.terminal, copyEnvironment(e.sess.env) | |
| 376 | + | e.sess.mu.Unlock() | |
| 377 | + | size := e.sess.fullViewportSize() | |
| 378 | + | e.d.mu.Lock() | |
| 379 | + | defer e.d.mu.Unlock() | |
| 380 | + | if e.d.closing { | |
| 381 | + | return errors.New("daemon is shutting down") | |
| 382 | + | } | |
| 383 | + | if e.d.nameLiveOrStoppedLocked(name) { | |
| 384 | + | return errSessionNameInUse | |
| 385 | + | } | |
| 386 | + | _, err := e.d.createSessionLocked(name, false, cwd, size, term, env) | |
| 387 | + | return err | |
| 388 | + | } | |
| 389 | + | func (e controlExec) CloseTab() error { | |
| 390 | + | return e.runAction(daemonActionRequest{kind: daemonActionCloseTab}) | |
| 391 | + | } | |
| 392 | + | func (e controlExec) ClosePane() error { | |
| 393 | + | return e.runAction(daemonActionRequest{kind: daemonActionClosePane}) | |
| 394 | + | } | |
| 395 | + | func (e controlExec) split(direction layout.Direction) error { | |
| 396 | + | return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction}) | |
| 397 | + | } | |
| 398 | + | func (e controlExec) SplitRight() error { return e.split(layout.Right) } | |
| 399 | + | func (e controlExec) SplitLeft() error { return e.split(layout.Left) } | |
| 400 | + | func (e controlExec) SplitUp() error { return e.split(layout.Up) } | |
| 401 | + | func (e controlExec) SplitDown() error { return e.split(layout.Down) } | |
| 402 | + | func (e controlExec) StackPane() error { | |
| 403 | + | return e.runAction(daemonActionRequest{kind: daemonActionStackPane}) | |
| 404 | + | } | |
| 405 | + | func (e controlExec) ToggleStack() error { | |
| 406 | + | return e.runAction(daemonActionRequest{kind: daemonActionToggleStack}) | |
| 407 | + | } | |
| 408 | + | func (e controlExec) focus(direction layout.Direction) error { | |
| 409 | + | return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction}) | |
| 410 | + | } | |
| 411 | + | func (e controlExec) FocusPaneLeft() error { return e.focus(layout.Left) } | |
| 412 | + | func (e controlExec) FocusPaneRight() error { return e.focus(layout.Right) } | |
| 413 | + | func (e controlExec) FocusPaneUp() error { return e.focus(layout.Up) } | |
| 414 | + | func (e controlExec) FocusPaneDown() error { return e.focus(layout.Down) } | |
| 415 | + | func (e controlExec) NextTab() error { | |
| 416 | + | return e.runAction(daemonActionRequest{kind: daemonActionNextTab}) | |
| 417 | + | } | |
| 418 | + | func (e controlExec) PrevTab() error { | |
| 419 | + | return e.runAction(daemonActionRequest{kind: daemonActionPreviousTab}) | |
| 420 | + | } | |
| 421 | + | func (e controlExec) RenameSessionTo(name string) error { | |
| 422 | + | return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name}) | |
| 423 | + | } | |
| 424 | + | func (e controlExec) RenameTabTo(name string) error { | |
| 425 | + | return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, name: name}) | |
| 426 | + | } | |
| 427 | + | func (e controlExec) Toast(severity, message string) error { | |
| 428 | + | var level domain.NoticeSeverity | |
| 429 | + | switch severity { | |
| 430 | + | case "info": | |
| 431 | + | level = domain.NoticeInfo | |
| 432 | + | case "warn": | |
| 433 | + | level = domain.NoticeWarn | |
| 434 | + | case "error": | |
| 435 | + | level = domain.NoticeError | |
| 436 | + | default: | |
| 437 | + | return command.ErrInvalidArguments | |
| 438 | + | } | |
| 439 | + | if strings.TrimSpace(message) == "" { | |
| 440 | + | return command.ErrInvalidArguments | |
| 441 | + | } | |
| 442 | + | e.d.notify(e.sess, level, domain.NoticeUser, message, nil) | |
| 443 | + | return nil | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | func (e controlExec) ListSessions(asJSON bool) (string, error) { | |
| 447 | + | type row struct { | |
| 448 | + | Name string `json:"name"` | |
| 449 | + | Ephemeral bool `json:"ephemeral"` | |
| 450 | + | Tabs int `json:"tabs"` | |
| 451 | + | Attached bool `json:"attached"` | |
| 452 | + | Active bool `json:"active"` | |
| 453 | + | } | |
| 454 | + | e.d.mu.Lock() | |
| 455 | + | sessions := make([]*session, 0, len(e.d.sessions)) | |
| 456 | + | for _, sess := range e.d.sessions { | |
| 457 | + | sessions = append(sessions, sess) | |
| 458 | + | } | |
| 459 | + | e.d.mu.Unlock() | |
| 460 | + | var active *session | |
| 461 | + | for _, sess := range sessions { | |
| 462 | + | if active == nil || sess.mruAt.Load() > active.mruAt.Load() { | |
| 463 | + | active = sess | |
| 464 | + | } | |
| 465 | + | } | |
| 466 | + | rows := make([]row, 0, len(sessions)) | |
| 467 | + | for _, sess := range sessions { | |
| 468 | + | sess.mu.Lock() | |
| 469 | + | rows = append(rows, row{Name: sess.name, Ephemeral: sess.ephemeral, Tabs: len(sess.tabs), Attached: sess.client != nil, Active: sess == active}) | |
| 470 | + | sess.mu.Unlock() | |
| 471 | + | } | |
| 472 | + | sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name }) | |
| 473 | + | if asJSON { | |
| 474 | + | return marshalListing(rows) | |
| 475 | + | } | |
| 476 | + | var out strings.Builder | |
| 477 | + | out.WriteString("NAME\tSTATE\tTABS\tATTACHED\tACTIVE\n") | |
| 478 | + | for _, row := range rows { | |
| 479 | + | state := "named" | |
| 480 | + | if row.Ephemeral { | |
| 481 | + | state = "ephemeral" | |
| 482 | + | } | |
| 483 | + | fmt.Fprintf(&out, "%s\t%s\t%d\t%t\t%t\n", row.Name, state, row.Tabs, row.Attached, row.Active) | |
| 484 | + | } | |
| 485 | + | return out.String(), nil | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | func (e controlExec) ListTabs(asJSON bool) (string, error) { | |
| 489 | + | type row struct { | |
| 490 | + | Index int `json:"index"` | |
| 491 | + | ID string `json:"id"` | |
| 492 | + | Name string `json:"name"` | |
| 493 | + | Panes int `json:"panes"` | |
| 494 | + | Active bool `json:"active"` | |
| 495 | + | } | |
| 496 | + | e.sess.mu.Lock() | |
| 497 | + | tabs := append([]*tab(nil), e.sess.tabs...) | |
| 498 | + | active := e.sess.active | |
| 499 | + | e.sess.mu.Unlock() | |
| 500 | + | rows := make([]row, 0, len(tabs)) | |
| 501 | + | for i, tb := range tabs { | |
| 502 | + | tb.mu.Lock() | |
| 503 | + | rows = append(rows, row{Index: i, ID: tb.stableID, Name: tb.name, Panes: len(tb.panes), Active: i == active}) | |
| 504 | + | tb.mu.Unlock() | |
| 505 | + | } | |
| 506 | + | if asJSON { | |
| 507 | + | return marshalListing(rows) | |
| 508 | + | } | |
| 509 | + | var out strings.Builder | |
| 510 | + | out.WriteString("INDEX\tID\tNAME\tPANES\tACTIVE\n") | |
| 511 | + | for _, row := range rows { | |
| 512 | + | fmt.Fprintf(&out, "%d\t%s\t%s\t%d\t%t\n", row.Index, row.ID, row.Name, row.Panes, row.Active) | |
| 513 | + | } | |
| 514 | + | return out.String(), nil | |
| 515 | + | } | |
| 516 | + | ||
| 517 | + | func (e controlExec) ListPanes(asJSON bool) (string, error) { | |
| 518 | + | type row struct { | |
| 519 | + | ID string `json:"id"` | |
| 520 | + | Pane string `json:"pane"` | |
| 521 | + | Size string `json:"size"` | |
| 522 | + | CWD string `json:"cwd"` | |
| 523 | + | Focused bool `json:"focused"` | |
| 524 | + | } | |
| 525 | + | tb := e.tab | |
| 526 | + | if tb == nil { | |
| 527 | + | tb = e.sess.activeTab() | |
| 528 | + | } | |
| 529 | + | if tb == nil { | |
| 530 | + | return "", errors.New("target session has no active tab") | |
| 531 | + | } | |
| 532 | + | e.sess.mu.Lock() | |
| 533 | + | fallbackCWD := e.sess.cwd | |
| 534 | + | e.sess.mu.Unlock() | |
| 535 | + | tb.mu.Lock() | |
| 536 | + | focus := tb.tree.Focus | |
| 537 | + | panes := tb.panesSnapshot() | |
| 538 | + | tb.mu.Unlock() | |
| 539 | + | rows := make([]row, 0, len(panes)) | |
| 540 | + | for _, pane := range panes { | |
| 541 | + | pane.mu.Lock() | |
| 542 | + | size := domain.Size{Cols: pane.rect.Width, Rows: pane.rect.Height} | |
| 543 | + | pid := 0 | |
| 544 | + | if pane.pty != nil { | |
| 545 | + | pid = pane.pty.Pid() | |
| 546 | + | } | |
| 547 | + | pane.mu.Unlock() | |
| 548 | + | cwd := fallbackCWD | |
| 549 | + | if e.d.procCwd != nil && pid > 0 { | |
| 550 | + | if live, err := e.d.procCwd(pid); err == nil && live != "" { | |
| 551 | + | cwd = live | |
| 552 | + | } | |
| 553 | + | } | |
| 554 | + | 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}) | |
| 555 | + | } | |
| 556 | + | sort.Slice(rows, func(i, j int) bool { return rows[i].Pane < rows[j].Pane }) | |
| 557 | + | if asJSON { | |
| 558 | + | return marshalListing(rows) | |
| 559 | + | } | |
| 560 | + | var out strings.Builder | |
| 561 | + | out.WriteString("ID\tPANE\tSIZE\tCWD\tFOCUSED\n") | |
| 562 | + | for _, row := range rows { | |
| 563 | + | fmt.Fprintf(&out, "%s\t%s\t%s\t%s\t%t\n", row.ID, row.Pane, row.Size, row.CWD, row.Focused) | |
| 564 | + | } | |
| 565 | + | return out.String(), nil | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | func marshalListing(value any) (string, error) { | |
| 569 | + | encoded, err := json.Marshal(value) | |
| 570 | + | if err != nil { | |
| 571 | + | return "", err | |
| 572 | + | } | |
| 573 | + | return string(encoded) + "\n", nil | |
| 574 | + | } | |
| @@ -0,0 +1,574 @@ | |||||
| 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 && !request.Self { | |||
| 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) | |||
| 57 | + | if code != 0 { | |||
| 58 | + | return commandFailure(code, text) | |||
| 59 | + | } | |||
| 60 | + | return d.runControl(cmd, controlExec{d: d, sess: sess, tab: tb, target: daemonActionTarget{session: 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), errors.Is(err, domain.ErrInvalidSessionName): | |||
| 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.Self && (request.TargetTab == "" || request.TargetPane == "") { | |||
| 91 | + | return nil, ports.ErrNoSuchTarget, "--self requires target tab and pane IDs" | |||
| 92 | + | } | |||
| 93 | + | if request.TargetSession != "" && request.TargetTab == "" && request.TargetPane == "" { | |||
| 94 | + | if named == nil { | |||
| 95 | + | return nil, ports.ErrNoSuchTarget, "no such session: " + request.TargetSession | |||
| 96 | + | } | |||
| 97 | + | return named, 0, "" | |||
| 98 | + | } | |||
| 99 | + | if request.TargetTab != "" || request.TargetPane != "" { | |||
| 100 | + | if request.TargetTab == "" || request.TargetPane == "" { | |||
| 101 | + | return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together" | |||
| 102 | + | } | |||
| 103 | + | for _, sess := range d.sessions { | |||
| 104 | + | if sess.containsStableIDs(request.TargetTab, request.TargetPane) { | |||
| 105 | + | if named != nil && named != sess { | |||
| 106 | + | return nil, ports.ErrNoSuchTarget, "tab/pane IDs belong to another session" | |||
| 107 | + | } | |||
| 108 | + | return sess, 0, "" | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | return nil, ports.ErrNoSuchTarget, "no live session contains the target tab/pane" | |||
| 112 | + | } | |||
| 113 | + | if len(d.sessions) == 0 { | |||
| 114 | + | return nil, ports.ErrNoSuchTarget, "no live sessions" | |||
| 115 | + | } | |||
| 116 | + | if len(d.sessions) != 1 { | |||
| 117 | + | return nil, ports.ErrAmbiguousTarget, "several sessions are live; use -s <session> or run from inside a pane" | |||
| 118 | + | } | |||
| 119 | + | for _, sess := range d.sessions { | |||
| 120 | + | return sess, 0, "" | |||
| 121 | + | } | |||
| 122 | + | panic("unreachable") | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func (s *session) containsStableIDs(tabID, paneID string) bool { | |||
| 126 | + | s.mu.Lock() | |||
| 127 | + | tabs := append([]*tab(nil), s.tabs...) | |||
| 128 | + | s.mu.Unlock() | |||
| 129 | + | for _, tb := range tabs { | |||
| 130 | + | tb.mu.Lock() | |||
| 131 | + | pane := paneByStableIDLocked(tb, paneID) | |||
| 132 | + | match := tb.stableID == tabID && pane != nil | |||
| 133 | + | tb.mu.Unlock() | |||
| 134 | + | if match { | |||
| 135 | + | return true | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | return false | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func resolveControlTarget(sess *session, kind command.TargetKind, request ports.CommandRequest) (*tab, *pane, uint16, string) { | |||
| 142 | + | sess.mu.Lock() | |||
| 143 | + | tabs := append([]*tab(nil), sess.tabs...) | |||
| 144 | + | active := sess.active | |||
| 145 | + | sess.mu.Unlock() | |||
| 146 | + | if request.Self { | |||
| 147 | + | for _, tb := range tabs { | |||
| 148 | + | tb.mu.Lock() | |||
| 149 | + | if tb.stableID != request.TargetTab { | |||
| 150 | + | tb.mu.Unlock() | |||
| 151 | + | continue | |||
| 152 | + | } | |||
| 153 | + | target := paneByStableIDLocked(tb, request.TargetPane) | |||
| 154 | + | tb.mu.Unlock() | |||
| 155 | + | if target == nil { | |||
| 156 | + | return nil, nil, ports.ErrNoSuchTarget, "target pane does not belong to target tab" | |||
| 157 | + | } | |||
| 158 | + | return tb, target, 0, "" | |||
| 159 | + | } | |||
| 160 | + | return nil, nil, ports.ErrNoSuchTarget, "no such target tab" | |||
| 161 | + | } | |||
| 162 | + | if len(tabs) == 0 || active < 0 || active >= len(tabs) { | |||
| 163 | + | return nil, nil, ports.ErrNoSuchTarget, "target session has no active tab" | |||
| 164 | + | } | |||
| 165 | + | tb := tabs[active] | |||
| 166 | + | if kind != command.TargetPane { | |||
| 167 | + | return tb, nil, 0, "" | |||
| 168 | + | } | |||
| 169 | + | tb.mu.Lock() | |||
| 170 | + | target := tb.focusedPane() | |||
| 171 | + | tb.mu.Unlock() | |||
| 172 | + | if target == nil { | |||
| 173 | + | return nil, nil, ports.ErrNoSuchTarget, "no such target pane" | |||
| 174 | + | } | |||
| 175 | + | return tb, target, 0, "" | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func paneByStableIDLocked(tb *tab, stableID string) *pane { | |||
| 179 | + | for _, pane := range tb.panes { | |||
| 180 | + | if pane.stableID == stableID { | |||
| 181 | + | return pane | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 184 | + | return nil | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | type daemonActionKind uint8 | |||
| 188 | + | ||||
| 189 | + | const ( | |||
| 190 | + | daemonActionCreateTab daemonActionKind = iota | |||
| 191 | + | daemonActionCloseTab | |||
| 192 | + | daemonActionSplitPane | |||
| 193 | + | daemonActionStackPane | |||
| 194 | + | daemonActionToggleStack | |||
| 195 | + | daemonActionClosePane | |||
| 196 | + | daemonActionFocusPane | |||
| 197 | + | daemonActionNextTab | |||
| 198 | + | daemonActionPreviousTab | |||
| 199 | + | daemonActionRenameSession | |||
| 200 | + | daemonActionRenameTab | |||
| 201 | + | ) | |||
| 202 | + | ||||
| 203 | + | type daemonActionTarget struct { | |||
| 204 | + | session *session | |||
| 205 | + | tab *tab | |||
| 206 | + | pane *pane | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | type daemonActionRequest struct { | |||
| 210 | + | kind daemonActionKind | |||
| 211 | + | target daemonActionTarget | |||
| 212 | + | direction layout.Direction | |||
| 213 | + | viewport domain.Size | |||
| 214 | + | name string | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | type daemonActionRunner interface { | |||
| 218 | + | Run(daemonActionRequest) error | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | func resolveDaemonActionTarget(sess *session) daemonActionTarget { | |||
| 222 | + | if sess == nil { | |||
| 223 | + | return daemonActionTarget{} | |||
| 224 | + | } | |||
| 225 | + | tb := sess.activeTab() | |||
| 226 | + | if tb == nil { | |||
| 227 | + | return daemonActionTarget{session: sess} | |||
| 228 | + | } | |||
| 229 | + | tb.mu.Lock() | |||
| 230 | + | pane := tb.focusedPane() | |||
| 231 | + | tb.mu.Unlock() | |||
| 232 | + | return daemonActionTarget{session: sess, tab: tb, pane: pane} | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | // daemonActions is the daemon-owned mutation seam shared by palette and | |||
| 236 | + | // control. Requests contain already-resolved targets and no attachment state; | |||
| 237 | + | // adapters remain responsible for their attached-client UI lifecycle. | |||
| 238 | + | type daemonActions struct{ d *Daemon } | |||
| 239 | + | ||||
| 240 | + | func (a daemonActions) Run(request daemonActionRequest) error { | |||
| 241 | + | target := request.target | |||
| 242 | + | switch request.kind { | |||
| 243 | + | case daemonActionCreateTab: | |||
| 244 | + | return a.d.createTab(target.session, request.viewport) | |||
| 245 | + | case daemonActionCloseTab: | |||
| 246 | + | return a.d.closeTab(target.session, target.tab, true) | |||
| 247 | + | case daemonActionSplitPane: | |||
| 248 | + | return a.d.splitPaneAt(target.session, target.tab, target.pane, request.direction) | |||
| 249 | + | case daemonActionStackPane: | |||
| 250 | + | return a.d.stackPaneAt(target.session, target.tab, target.pane) | |||
| 251 | + | case daemonActionToggleStack: | |||
| 252 | + | return a.d.toggleStackAt(target.session, target.tab, target.pane) | |||
| 253 | + | case daemonActionClosePane: | |||
| 254 | + | if a.d.ptys == nil { | |||
| 255 | + | return nil | |||
| 256 | + | } | |||
| 257 | + | if !a.d.hasDaemonActionPaneTarget(target) { | |||
| 258 | + | return layout.ErrNotFound | |||
| 259 | + | } | |||
| 260 | + | if err := a.d.closePane(target.session, target.tab, target.pane.id, nil, true); err != nil { | |||
| 261 | + | return err | |||
| 262 | + | } | |||
| 263 | + | if a.d.hasDaemonActionPaneTarget(target) { | |||
| 264 | + | return errors.New("pane close did not complete") | |||
| 265 | + | } | |||
| 266 | + | return nil | |||
| 267 | + | case daemonActionFocusPane: | |||
| 268 | + | return a.d.focusDirAt(target.session, target.tab, target.pane, request.direction) | |||
| 269 | + | case daemonActionNextTab: | |||
| 270 | + | return a.switchRelative(target.session, 1) | |||
| 271 | + | case daemonActionPreviousTab: | |||
| 272 | + | return a.switchRelative(target.session, -1) | |||
| 273 | + | case daemonActionRenameSession: | |||
| 274 | + | return a.d.renameSession(target.session, request.name) | |||
| 275 | + | case daemonActionRenameTab: | |||
| 276 | + | return a.d.renameTab(target.session, target.tab, request.name) | |||
| 277 | + | default: | |||
| 278 | + | return errors.New("daemon: unknown action") | |||
| 279 | + | } | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | // hasDaemonActionPaneTarget verifies that an already-resolved pane still | |||
| 283 | + | // belongs to the live tab and session. Close-pane uses it both before mutation | |||
| 284 | + | // and as a postcondition because closing a final pane delegates to close-tab. | |||
| 285 | + | func (d *Daemon) hasDaemonActionPaneTarget(target daemonActionTarget) bool { | |||
| 286 | + | if target.session == nil || target.tab == nil || target.pane == nil { | |||
| 287 | + | return false | |||
| 288 | + | } | |||
| 289 | + | d.mu.Lock() | |||
| 290 | + | defer d.mu.Unlock() | |||
| 291 | + | if d.sessions[target.session.id] != target.session { | |||
| 292 | + | return false | |||
| 293 | + | } | |||
| 294 | + | target.session.mu.Lock() | |||
| 295 | + | defer target.session.mu.Unlock() | |||
| 296 | + | foundTab := false | |||
| 297 | + | for _, tb := range target.session.tabs { | |||
| 298 | + | if tb == target.tab { | |||
| 299 | + | foundTab = true | |||
| 300 | + | break | |||
| 301 | + | } | |||
| 302 | + | } | |||
| 303 | + | if !foundTab { | |||
| 304 | + | return false | |||
| 305 | + | } | |||
| 306 | + | target.tab.mu.Lock() | |||
| 307 | + | defer target.tab.mu.Unlock() | |||
| 308 | + | return target.tab.panes[target.pane.id] == target.pane && | |||
| 309 | + | target.tab.tree != nil && layout.ContainsLeaf(target.tab.tree.Root, target.pane.id) | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | func (a daemonActions) switchRelative(sess *session, delta int) error { | |||
| 313 | + | if sess.switchRelative(delta) { | |||
| 314 | + | a.d.activateTab(sess, sess.activeTab()) | |||
| 315 | + | } | |||
| 316 | + | return nil | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | // controlExec implements command.ControlContext against resolved daemon-owned | |||
| 320 | + | // targets. It deliberately contains no attached-client state. | |||
| 321 | + | type controlExec struct { | |||
| 322 | + | d *Daemon | |||
| 323 | + | sess *session | |||
| 324 | + | tab *tab | |||
| 325 | + | target daemonActionTarget | |||
| 326 | + | actions daemonActionRunner | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | func (e controlExec) runAction(request daemonActionRequest) error { | |||
| 330 | + | if request.target.session == nil { | |||
| 331 | + | request.target = e.target | |||
| 332 | + | } | |||
| 333 | + | runner := e.actions | |||
| 334 | + | if runner == nil { | |||
| 335 | + | runner = daemonActions{d: e.d} | |||
| 336 | + | } | |||
| 337 | + | err := runner.Run(request) | |||
| 338 | + | if err == nil && e.actions == nil { | |||
| 339 | + | e.sess.mu.Lock() | |||
| 340 | + | ac := e.sess.client | |||
| 341 | + | e.sess.mu.Unlock() | |||
| 342 | + | finishDaemonActionForClient(e.d, request, ac, "control.go") | |||
| 343 | + | } | |||
| 344 | + | return err | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *attachedClient, producer string) { | |||
| 348 | + | if ac == nil { | |||
| 349 | + | return | |||
| 350 | + | } | |||
| 351 | + | if request.kind == daemonActionFocusPane && request.target.tab != nil && request.target.pane != nil { | |||
| 352 | + | tb := request.target.tab | |||
| 353 | + | tb.mu.Lock() | |||
| 354 | + | newFocus := tb.tree.Focus | |||
| 355 | + | pl, hasPlacement := focusedPlacementLocked(tb) | |||
| 356 | + | tb.mu.Unlock() | |||
| 357 | + | if newFocus != request.target.pane.id { | |||
| 358 | + | d.exitCopyMode(ac) | |||
| 359 | + | if hasPlacement && pl.TitleBar.Height > 0 { | |||
| 360 | + | d.refreshPaneTitleOnFocus(request.target.session, newFocus) | |||
| 361 | + | } | |||
| 362 | + | } | |||
| 363 | + | } | |||
| 364 | + | d.invalidateRender(request.target.session, ac, true, producer) | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | func (e controlExec) CreateTab() error { | |||
| 368 | + | return e.runAction(daemonActionRequest{kind: daemonActionCreateTab, viewport: e.sess.fullViewportSize()}) | |||
| 369 | + | } | |||
| 370 | + | func (e controlExec) CreateSessionNamed(name string) error { | |||
| 371 | + | if err := domain.ValidateSessionName(name); err != nil { | |||
| 372 | + | return command.ErrInvalidArguments | |||
| 373 | + | } | |||
| 374 | + | e.sess.mu.Lock() | |||
| 375 | + | cwd, term, env := e.sess.cwd, e.sess.terminal, copyEnvironment(e.sess.env) | |||
| 376 | + | e.sess.mu.Unlock() | |||
| 377 | + | size := e.sess.fullViewportSize() | |||
| 378 | + | e.d.mu.Lock() | |||
| 379 | + | defer e.d.mu.Unlock() | |||
| 380 | + | if e.d.closing { | |||
| 381 | + | return errors.New("daemon is shutting down") | |||
| 382 | + | } | |||
| 383 | + | if e.d.nameLiveOrStoppedLocked(name) { | |||
| 384 | + | return errSessionNameInUse | |||
| 385 | + | } | |||
| 386 | + | _, err := e.d.createSessionLocked(name, false, cwd, size, term, env) | |||
| 387 | + | return err | |||
| 388 | + | } | |||
| 389 | + | func (e controlExec) CloseTab() error { | |||
| 390 | + | return e.runAction(daemonActionRequest{kind: daemonActionCloseTab}) | |||
| 391 | + | } | |||
| 392 | + | func (e controlExec) ClosePane() error { | |||
| 393 | + | return e.runAction(daemonActionRequest{kind: daemonActionClosePane}) | |||
| 394 | + | } | |||
| 395 | + | func (e controlExec) split(direction layout.Direction) error { | |||
| 396 | + | return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction}) | |||
| 397 | + | } | |||
| 398 | + | func (e controlExec) SplitRight() error { return e.split(layout.Right) } | |||
| 399 | + | func (e controlExec) SplitLeft() error { return e.split(layout.Left) } | |||
| 400 | + | func (e controlExec) SplitUp() error { return e.split(layout.Up) } | |||
| 401 | + | func (e controlExec) SplitDown() error { return e.split(layout.Down) } | |||
| 402 | + | func (e controlExec) StackPane() error { | |||
| 403 | + | return e.runAction(daemonActionRequest{kind: daemonActionStackPane}) | |||
| 404 | + | } | |||
| 405 | + | func (e controlExec) ToggleStack() error { | |||
| 406 | + | return e.runAction(daemonActionRequest{kind: daemonActionToggleStack}) | |||
| 407 | + | } | |||
| 408 | + | func (e controlExec) focus(direction layout.Direction) error { | |||
| 409 | + | return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction}) | |||
| 410 | + | } | |||
| 411 | + | func (e controlExec) FocusPaneLeft() error { return e.focus(layout.Left) } | |||
| 412 | + | func (e controlExec) FocusPaneRight() error { return e.focus(layout.Right) } | |||
| 413 | + | func (e controlExec) FocusPaneUp() error { return e.focus(layout.Up) } | |||
| 414 | + | func (e controlExec) FocusPaneDown() error { return e.focus(layout.Down) } | |||
| 415 | + | func (e controlExec) NextTab() error { | |||
| 416 | + | return e.runAction(daemonActionRequest{kind: daemonActionNextTab}) | |||
| 417 | + | } | |||
| 418 | + | func (e controlExec) PrevTab() error { | |||
| 419 | + | return e.runAction(daemonActionRequest{kind: daemonActionPreviousTab}) | |||
| 420 | + | } | |||
| 421 | + | func (e controlExec) RenameSessionTo(name string) error { | |||
| 422 | + | return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name}) | |||
| 423 | + | } | |||
| 424 | + | func (e controlExec) RenameTabTo(name string) error { | |||
| 425 | + | return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, name: name}) | |||
| 426 | + | } | |||
| 427 | + | func (e controlExec) Toast(severity, message string) error { | |||
| 428 | + | var level domain.NoticeSeverity | |||
| 429 | + | switch severity { | |||
| 430 | + | case "info": | |||
| 431 | + | level = domain.NoticeInfo | |||
| 432 | + | case "warn": | |||
| 433 | + | level = domain.NoticeWarn | |||
| 434 | + | case "error": | |||
| 435 | + | level = domain.NoticeError | |||
| 436 | + | default: | |||
| 437 | + | return command.ErrInvalidArguments | |||
| 438 | + | } | |||
| 439 | + | if strings.TrimSpace(message) == "" { | |||
| 440 | + | return command.ErrInvalidArguments | |||
| 441 | + | } | |||
| 442 | + | e.d.notify(e.sess, level, domain.NoticeUser, message, nil) | |||
| 443 | + | return nil | |||
| 444 | + | } | |||
| 445 | + | ||||
| 446 | + | func (e controlExec) ListSessions(asJSON bool) (string, error) { | |||
| 447 | + | type row struct { | |||
| 448 | + | Name string `json:"name"` | |||
| 449 | + | Ephemeral bool `json:"ephemeral"` | |||
| 450 | + | Tabs int `json:"tabs"` | |||
| 451 | + | Attached bool `json:"attached"` | |||
| 452 | + | Active bool `json:"active"` | |||
| 453 | + | } | |||
| 454 | + | e.d.mu.Lock() | |||
| 455 | + | sessions := make([]*session, 0, len(e.d.sessions)) | |||
| 456 | + | for _, sess := range e.d.sessions { | |||
| 457 | + | sessions = append(sessions, sess) | |||
| 458 | + | } | |||
| 459 | + | e.d.mu.Unlock() | |||
| 460 | + | var active *session | |||
| 461 | + | for _, sess := range sessions { | |||
| 462 | + | if active == nil || sess.mruAt.Load() > active.mruAt.Load() { | |||
| 463 | + | active = sess | |||
| 464 | + | } | |||
| 465 | + | } | |||
| 466 | + | rows := make([]row, 0, len(sessions)) | |||
| 467 | + | for _, sess := range sessions { | |||
| 468 | + | sess.mu.Lock() | |||
| 469 | + | rows = append(rows, row{Name: sess.name, Ephemeral: sess.ephemeral, Tabs: len(sess.tabs), Attached: sess.client != nil, Active: sess == active}) | |||
| 470 | + | sess.mu.Unlock() | |||
| 471 | + | } | |||
| 472 | + | sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name }) | |||
| 473 | + | if asJSON { | |||
| 474 | + | return marshalListing(rows) | |||
| 475 | + | } | |||
| 476 | + | var out strings.Builder | |||
| 477 | + | out.WriteString("NAME\tSTATE\tTABS\tATTACHED\tACTIVE\n") | |||
| 478 | + | for _, row := range rows { | |||
| 479 | + | state := "named" | |||
| 480 | + | if row.Ephemeral { | |||
| 481 | + | state = "ephemeral" | |||
| 482 | + | } | |||
| 483 | + | fmt.Fprintf(&out, "%s\t%s\t%d\t%t\t%t\n", row.Name, state, row.Tabs, row.Attached, row.Active) | |||
| 484 | + | } | |||
| 485 | + | return out.String(), nil | |||
| 486 | + | } | |||
| 487 | + | ||||
| 488 | + | func (e controlExec) ListTabs(asJSON bool) (string, error) { | |||
| 489 | + | type row struct { | |||
| 490 | + | Index int `json:"index"` | |||
| 491 | + | ID string `json:"id"` | |||
| 492 | + | Name string `json:"name"` | |||
| 493 | + | Panes int `json:"panes"` | |||
| 494 | + | Active bool `json:"active"` | |||
| 495 | + | } | |||
| 496 | + | e.sess.mu.Lock() | |||
| 497 | + | tabs := append([]*tab(nil), e.sess.tabs...) | |||
| 498 | + | active := e.sess.active | |||
| 499 | + | e.sess.mu.Unlock() | |||
| 500 | + | rows := make([]row, 0, len(tabs)) | |||
| 501 | + | for i, tb := range tabs { | |||
| 502 | + | tb.mu.Lock() | |||
| 503 | + | rows = append(rows, row{Index: i, ID: tb.stableID, Name: tb.name, Panes: len(tb.panes), Active: i == active}) | |||
| 504 | + | tb.mu.Unlock() | |||
| 505 | + | } | |||
| 506 | + | if asJSON { | |||
| 507 | + | return marshalListing(rows) | |||
| 508 | + | } | |||
| 509 | + | var out strings.Builder | |||
| 510 | + | out.WriteString("INDEX\tID\tNAME\tPANES\tACTIVE\n") | |||
| 511 | + | for _, row := range rows { | |||
| 512 | + | fmt.Fprintf(&out, "%d\t%s\t%s\t%d\t%t\n", row.Index, row.ID, row.Name, row.Panes, row.Active) | |||
| 513 | + | } | |||
| 514 | + | return out.String(), nil | |||
| 515 | + | } | |||
| 516 | + | ||||
| 517 | + | func (e controlExec) ListPanes(asJSON bool) (string, error) { | |||
| 518 | + | type row struct { | |||
| 519 | + | ID string `json:"id"` | |||
| 520 | + | Pane string `json:"pane"` | |||
| 521 | + | Size string `json:"size"` | |||
| 522 | + | CWD string `json:"cwd"` | |||
| 523 | + | Focused bool `json:"focused"` | |||
| 524 | + | } | |||
| 525 | + | tb := e.tab | |||
| 526 | + | if tb == nil { | |||
| 527 | + | tb = e.sess.activeTab() | |||
| 528 | + | } | |||
| 529 | + | if tb == nil { | |||
| 530 | + | return "", errors.New("target session has no active tab") | |||
| 531 | + | } | |||
| 532 | + | e.sess.mu.Lock() | |||
| 533 | + | fallbackCWD := e.sess.cwd | |||
| 534 | + | e.sess.mu.Unlock() | |||
| 535 | + | tb.mu.Lock() | |||
| 536 | + | focus := tb.tree.Focus | |||
| 537 | + | panes := tb.panesSnapshot() | |||
| 538 | + | tb.mu.Unlock() | |||
| 539 | + | rows := make([]row, 0, len(panes)) | |||
| 540 | + | for _, pane := range panes { | |||
| 541 | + | pane.mu.Lock() | |||
| 542 | + | size := domain.Size{Cols: pane.rect.Width, Rows: pane.rect.Height} | |||
| 543 | + | pid := 0 | |||
| 544 | + | if pane.pty != nil { | |||
| 545 | + | pid = pane.pty.Pid() | |||
| 546 | + | } | |||
| 547 | + | pane.mu.Unlock() | |||
| 548 | + | cwd := fallbackCWD | |||
| 549 | + | if e.d.procCwd != nil && pid > 0 { | |||
| 550 | + | if live, err := e.d.procCwd(pid); err == nil && live != "" { | |||
| 551 | + | cwd = live | |||
| 552 | + | } | |||
| 553 | + | } | |||
| 554 | + | 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}) | |||
| 555 | + | } | |||
| 556 | + | sort.Slice(rows, func(i, j int) bool { return rows[i].Pane < rows[j].Pane }) | |||
| 557 | + | if asJSON { | |||
| 558 | + | return marshalListing(rows) | |||
| 559 | + | } | |||
| 560 | + | var out strings.Builder | |||
| 561 | + | out.WriteString("ID\tPANE\tSIZE\tCWD\tFOCUSED\n") | |||
| 562 | + | for _, row := range rows { | |||
| 563 | + | fmt.Fprintf(&out, "%s\t%s\t%s\t%s\t%t\n", row.ID, row.Pane, row.Size, row.CWD, row.Focused) | |||
| 564 | + | } | |||
| 565 | + | return out.String(), nil | |||
| 566 | + | } | |||
| 567 | + | ||||
| 568 | + | func marshalListing(value any) (string, error) { | |||
| 569 | + | encoded, err := json.Marshal(value) | |||
| 570 | + | if err != nil { | |||
| 571 | + | return "", err | |||
| 572 | + | } | |||
| 573 | + | return string(encoded) + "\n", nil | |||
| 574 | + | } | |||
A internal/usecase/daemon/control_test.go +589 -0
| @@ -0,0 +1,589 @@ | |||
| 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 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 13 | + | "github.com/bnema/vev/internal/usecase/command" | |
| 14 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func TestPaletteAndControlShareExplicitDaemonActionTarget(t *testing.T) { | |
| 19 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 20 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 21 | + | tb := sess.activeTab() | |
| 22 | + | tb.mu.Lock() | |
| 23 | + | pane := tb.focusedPane() | |
| 24 | + | tb.mu.Unlock() | |
| 25 | + | spy := &actionRunnerSpy{} | |
| 26 | + | ||
| 27 | + | require.NoError(t, (paletteExec{d: d, sess: sess, actions: spy}).SplitRight()) | |
| 28 | + | require.NoError(t, (controlExec{ | |
| 29 | + | d: d, sess: sess, tab: tb, actions: spy, | |
| 30 | + | target: daemonActionTarget{session: sess, tab: tb, pane: pane}, | |
| 31 | + | }).SplitRight()) | |
| 32 | + | ||
| 33 | + | require.Len(t, spy.requests, 2) | |
| 34 | + | for _, request := range spy.requests { | |
| 35 | + | require.Equal(t, daemonActionSplitPane, request.kind) | |
| 36 | + | require.Same(t, sess, request.target.session) | |
| 37 | + | require.Same(t, tb, request.target.tab) | |
| 38 | + | require.Same(t, pane, request.target.pane) | |
| 39 | + | require.Equal(t, layout.Right, request.direction) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type actionRunnerSpy struct { | |
| 44 | + | requests []daemonActionRequest | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (s *actionRunnerSpy) Run(request daemonActionRequest) error { | |
| 48 | + | s.requests = append(s.requests, request) | |
| 49 | + | return nil | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestHandleCommandRejectsVersionBeforeDecodeOrDispatch(t *testing.T) { | |
| 53 | + | // The version prefix is valid and mismatched, while the remainder is not a | |
| 54 | + | // decodable request. The daemon must still return ErrVersionMismatch. | |
| 55 | + | frame := ports.Frame{Type: ports.MsgCommand, Payload: []byte{0, byte(ports.ProtocolVersion + 1)}} | |
| 56 | + | tr, sends, _ := newConn(t, frame) | |
| 57 | + | ||
| 58 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 59 | + | d.handleCommand(tr, frame) | |
| 60 | + | ||
| 61 | + | result := awaitCommandResult(t, sends) | |
| 62 | + | require.False(t, result.OK) | |
| 63 | + | require.Equal(t, ports.ErrVersionMismatch, result.Code) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestHandleConnRoutesCommand(t *testing.T) { | |
| 67 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 68 | + | request := ports.CommandRequest{Version: ports.ProtocolVersion, Slug: "list-sessions"} | |
| 69 | + | payload, err := ports.MarshalCommandRequest(request) | |
| 70 | + | require.NoError(t, err) | |
| 71 | + | frame := ports.Frame{Type: ports.MsgCommand, Payload: payload} | |
| 72 | + | tr, sends, _ := newConn(t, frame) | |
| 73 | + | ||
| 74 | + | d.handleConn(tr) | |
| 75 | + | ||
| 76 | + | result := awaitCommandResult(t, sends) | |
| 77 | + | require.True(t, result.OK, result.Text) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestHandleCommandDispatchAndTargetErrors(t *testing.T) { | |
| 81 | + | tests := []struct { | |
| 82 | + | name string | |
| 83 | + | arrange func(*Daemon) | |
| 84 | + | request ports.CommandRequest | |
| 85 | + | code uint16 | |
| 86 | + | }{ | |
| 87 | + | {name: "unknown slug", request: ports.CommandRequest{Slug: "no-such"}, code: ports.ErrUnknownCommand}, | |
| 88 | + | {name: "non-scriptable command", request: ports.CommandRequest{Slug: "session-picker"}, code: ports.ErrNotScriptable}, | |
| 89 | + | {name: "no sessions", request: ports.CommandRequest{Slug: "split-right"}, code: ports.ErrNoSuchTarget}, | |
| 90 | + | {name: "ambiguous sessions", arrange: func(d *Daemon) { | |
| 91 | + | addControlSession(d, "one", "t_one", "p_one") | |
| 92 | + | addControlSession(d, "two", "t_two", "p_two") | |
| 93 | + | }, request: ports.CommandRequest{Slug: "split-right"}, code: ports.ErrAmbiguousTarget}, | |
| 94 | + | {name: "missing explicit session", arrange: func(d *Daemon) { | |
| 95 | + | addControlSession(d, "work", "t_work", "p_work") | |
| 96 | + | }, request: ports.CommandRequest{Slug: "new-tab", TargetSession: "missing"}, code: ports.ErrNoSuchTarget}, | |
| 97 | + | {name: "missing stable IDs", arrange: func(d *Daemon) { | |
| 98 | + | addControlSession(d, "work", "t_work", "p_work") | |
| 99 | + | }, request: ports.CommandRequest{Slug: "split-right", TargetTab: "t_nope", TargetPane: "p_nope"}, code: ports.ErrNoSuchTarget}, | |
| 100 | + | {name: "cross-session IDs rejected", arrange: func(d *Daemon) { | |
| 101 | + | addControlSession(d, "one", "t_one", "p_one") | |
| 102 | + | addControlSession(d, "two", "t_two", "p_two") | |
| 103 | + | }, request: ports.CommandRequest{Slug: "split-right", TargetSession: "one", TargetTab: "t_two", TargetPane: "p_two"}, code: ports.ErrNoSuchTarget}, | |
| 104 | + | {name: "self requires both IDs", arrange: func(d *Daemon) { | |
| 105 | + | addControlSession(d, "work", "t_work", "p_work") | |
| 106 | + | }, request: ports.CommandRequest{Slug: "split-right", Self: true, TargetSession: "work", TargetTab: "t_work"}, code: ports.ErrNoSuchTarget}, | |
| 107 | + | {name: "self rejects pane from another tab", arrange: func(d *Daemon) { | |
| 108 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 109 | + | other := newTabWithStableID("t_other", "p_other", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 110 | + | other.ctx, other.cancel = context.WithCancel(d.serveCtx) | |
| 111 | + | sess.tabs = append(sess.tabs, other) | |
| 112 | + | }, request: ports.CommandRequest{Slug: "split-right", Self: true, TargetSession: "work", TargetTab: "t_work", TargetPane: "p_other"}, code: ports.ErrNoSuchTarget}, | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | for _, tt := range tests { | |
| 116 | + | t.Run(tt.name, func(t *testing.T) { | |
| 117 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 118 | + | if tt.arrange != nil { | |
| 119 | + | tt.arrange(d) | |
| 120 | + | } | |
| 121 | + | result := sendCommand(t, d, tt.request) | |
| 122 | + | require.False(t, result.OK) | |
| 123 | + | require.Equal(t, tt.code, result.Code) | |
| 124 | + | }) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestHandleCommandResolvesStaleSessionNameByStableIDs(t *testing.T) { | |
| 129 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 130 | + | sess := addControlSession(d, "renamed", "t_work", "p_work") | |
| 131 | + | ||
| 132 | + | result := sendCommand(t, d, ports.CommandRequest{ | |
| 133 | + | Slug: "toast", Args: []string{"hello"}, | |
| 134 | + | TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work", | |
| 135 | + | }) | |
| 136 | + | ||
| 137 | + | require.True(t, result.OK, result.Text) | |
| 138 | + | require.Equal(t, uint64(1), sess.mruAt.Load()) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func TestHandleCommandStableIDsResolveSessionWithoutSelectingTab(t *testing.T) { | |
| 142 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 143 | + | sess := addControlSession(d, "work", "t_first", "p_first") | |
| 144 | + | first := sess.tabs[0] | |
| 145 | + | second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 146 | + | second.ctx, second.cancel = context.WithCancel(d.serveCtx) | |
| 147 | + | sess.mu.Lock() | |
| 148 | + | sess.tabs = append(sess.tabs, second) | |
| 149 | + | sess.active = 0 | |
| 150 | + | sess.mu.Unlock() | |
| 151 | + | ||
| 152 | + | result := sendCommand(t, d, ports.CommandRequest{ | |
| 153 | + | Slug: "rename-tab", Args: []string{"targeted"}, TargetSession: "work", | |
| 154 | + | TargetTab: "t_second", TargetPane: "p_second", | |
| 155 | + | }) | |
| 156 | + | ||
| 157 | + | require.True(t, result.OK, result.Text) | |
| 158 | + | first.mu.Lock() | |
| 159 | + | require.Equal(t, "targeted", first.name) | |
| 160 | + | first.mu.Unlock() | |
| 161 | + | second.mu.Lock() | |
| 162 | + | require.Empty(t, second.name) | |
| 163 | + | second.mu.Unlock() | |
| 164 | + | sess.mu.Lock() | |
| 165 | + | require.Zero(t, sess.active) | |
| 166 | + | sess.mu.Unlock() | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func TestHandleCommandStableIDsDoNotRedirectSplitFromCurrentFocus(t *testing.T) { | |
| 170 | + | factory := &controlPTYFactory{} | |
| 171 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 172 | + | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |
| 173 | + | sess := addControlSession(d, "work", "t_active", "p_active") | |
| 174 | + | active := sess.tabs[0] | |
| 175 | + | originFocus := active.tree.Focus | |
| 176 | + | origin := active.focusedPane() | |
| 177 | + | second := newTabWithStableID("t_origin", "p_origin", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 178 | + | second.ctx, second.cancel = context.WithCancel(d.serveCtx) | |
| 179 | + | sess.mu.Lock() | |
| 180 | + | sess.tabs = append(sess.tabs, second) | |
| 181 | + | sess.active = 0 | |
| 182 | + | sess.mu.Unlock() | |
| 183 | + | ||
| 184 | + | result := sendCommand(t, d, ports.CommandRequest{ | |
| 185 | + | Slug: "split-right", TargetSession: "work", | |
| 186 | + | TargetTab: "t_origin", TargetPane: "p_origin", | |
| 187 | + | }) | |
| 188 | + | ||
| 189 | + | require.True(t, result.OK, result.Text) | |
| 190 | + | sess.mu.Lock() | |
| 191 | + | activeIndex := sess.active | |
| 192 | + | sess.mu.Unlock() | |
| 193 | + | require.Zero(t, activeIndex) | |
| 194 | + | active.mu.Lock() | |
| 195 | + | activePaneCount := len(active.panes) | |
| 196 | + | activeFocus := active.tree.Focus | |
| 197 | + | originRetained := active.panes[origin.id] == origin | |
| 198 | + | active.mu.Unlock() | |
| 199 | + | require.Equal(t, 2, activePaneCount, "split must mutate the daemon-focused tab") | |
| 200 | + | require.NotEqual(t, originFocus, activeFocus, "split must focus the new pane beside the daemon-focused pane") | |
| 201 | + | require.True(t, originRetained) | |
| 202 | + | second.mu.Lock() | |
| 203 | + | secondPaneCount := len(second.panes) | |
| 204 | + | second.mu.Unlock() | |
| 205 | + | require.Equal(t, 1, secondPaneCount, "stable IDs are only a session locator") | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) { | |
| 209 | + | factory := &controlPTYFactory{} | |
| 210 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 211 | + | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |
| 212 | + | sess := addControlSession(d, "work", "t_active", "p_active") | |
| 213 | + | active := sess.tabs[0] | |
| 214 | + | invoking := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 215 | + | invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx) | |
| 216 | + | sess.mu.Lock() | |
| 217 | + | sess.tabs = append(sess.tabs, invoking) | |
| 218 | + | sess.active = 0 | |
| 219 | + | sess.mu.Unlock() | |
| 220 | + | ||
| 221 | + | rename := sendCommand(t, d, ports.CommandRequest{ | |
| 222 | + | Slug: "rename-tab", Args: []string{"invoking"}, Self: true, | |
| 223 | + | TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking", | |
| 224 | + | }) | |
| 225 | + | require.True(t, rename.OK, rename.Text) | |
| 226 | + | active.mu.Lock() | |
| 227 | + | require.Empty(t, active.name) | |
| 228 | + | active.mu.Unlock() | |
| 229 | + | invoking.mu.Lock() | |
| 230 | + | require.Equal(t, "invoking", invoking.name) | |
| 231 | + | invoking.mu.Unlock() | |
| 232 | + | ||
| 233 | + | split := sendCommand(t, d, ports.CommandRequest{ | |
| 234 | + | Slug: "split-right", Self: true, | |
| 235 | + | TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking", | |
| 236 | + | }) | |
| 237 | + | require.True(t, split.OK, split.Text) | |
| 238 | + | active.mu.Lock() | |
| 239 | + | require.Len(t, active.panes, 1) | |
| 240 | + | active.mu.Unlock() | |
| 241 | + | invoking.mu.Lock() | |
| 242 | + | require.Len(t, invoking.panes, 2) | |
| 243 | + | invoking.mu.Unlock() | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | func TestHandleCommandSelfListPanesUsesInvokingTab(t *testing.T) { | |
| 247 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 248 | + | sess := addControlSession(d, "work", "t_active", "p_active") | |
| 249 | + | invoking := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 250 | + | invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx) | |
| 251 | + | sess.mu.Lock() | |
| 252 | + | sess.tabs = append(sess.tabs, invoking) | |
| 253 | + | sess.active = 0 | |
| 254 | + | sess.mu.Unlock() | |
| 255 | + | ||
| 256 | + | result := sendCommand(t, d, ports.CommandRequest{ | |
| 257 | + | Slug: "list-panes", Self: true, JSON: true, | |
| 258 | + | TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking", | |
| 259 | + | }) | |
| 260 | + | require.True(t, result.OK, result.Text) | |
| 261 | + | require.Contains(t, result.Output, "p_invoking") | |
| 262 | + | require.NotContains(t, result.Output, "p_active") | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | func TestHandleCommandRenameSessionRejectsInvalidNameAsCommandArgs(t *testing.T) { | |
| 266 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 267 | + | addControlSession(d, "work", "t_work", "p_work") | |
| 268 | + | ||
| 269 | + | result := sendCommand(t, d, ports.CommandRequest{ | |
| 270 | + | Slug: "rename-session", Args: []string{"invalid name"}, TargetSession: "work", | |
| 271 | + | }) | |
| 272 | + | ||
| 273 | + | require.False(t, result.OK) | |
| 274 | + | require.Equal(t, ports.ErrInvalidCommandArgs, result.Code) | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | func TestCloseCommandsReportMutationOutcome(t *testing.T) { | |
| 278 | + | t.Run("stale close tab target fails", func(t *testing.T) { | |
| 279 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 280 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 281 | + | stale := newTabWithStableID("t_stale", "p_stale", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 282 | + | cmd, ok := command.BySlug("close-tab") | |
| 283 | + | require.True(t, ok) | |
| 284 | + | ||
| 285 | + | result := d.runControl(cmd, controlExec{ | |
| 286 | + | d: d, sess: sess, tab: stale, | |
| 287 | + | target: daemonActionTarget{session: sess, tab: stale}, | |
| 288 | + | }, ports.CommandRequest{Slug: "close-tab"}) | |
| 289 | + | ||
| 290 | + | require.False(t, result.OK) | |
| 291 | + | require.Equal(t, ports.ErrInternal, result.Code) | |
| 292 | + | require.Len(t, sess.tabs, 1, "a stale close must retain the live tab") | |
| 293 | + | }) | |
| 294 | + | ||
| 295 | + | t.Run("failed final pane close fails and retains session", func(t *testing.T) { | |
| 296 | + | d := newTestDaemon(t, &controlPTYFactory{}, stubClock{}) | |
| 297 | + | d.snapsEnabled = true | |
| 298 | + | d.snapshotRepository = portsmocks.NewMockSnapshotRepository(t) | |
| 299 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 300 | + | sess.mu.Lock() | |
| 301 | + | sess.ephemeral = false | |
| 302 | + | sess.mu.Unlock() | |
| 303 | + | sess.snapEligible.Store(true) | |
| 304 | + | ||
| 305 | + | result := sendCommand(t, d, ports.CommandRequest{Slug: "close-pane", TargetSession: "work"}) | |
| 306 | + | ||
| 307 | + | require.False(t, result.OK) | |
| 308 | + | require.Equal(t, ports.ErrInternal, result.Code) | |
| 309 | + | d.mu.Lock() | |
| 310 | + | require.Same(t, sess, d.sessions[sess.id], "failed final-pane close must retain the session") | |
| 311 | + | d.mu.Unlock() | |
| 312 | + | sess.mu.Lock() | |
| 313 | + | require.Len(t, sess.tabs, 1) | |
| 314 | + | sess.mu.Unlock() | |
| 315 | + | }) | |
| 316 | + | ||
| 317 | + | t.Run("successful close tab remains successful", func(t *testing.T) { | |
| 318 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 319 | + | sess := addControlSession(d, "work", "t_first", "p_first") | |
| 320 | + | second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 321 | + | second.ctx, second.cancel = context.WithCancel(d.serveCtx) | |
| 322 | + | sess.mu.Lock() | |
| 323 | + | sess.tabs = append(sess.tabs, second) | |
| 324 | + | sess.active = 1 | |
| 325 | + | sess.mu.Unlock() | |
| 326 | + | ||
| 327 | + | result := sendCommand(t, d, ports.CommandRequest{Slug: "close-tab", TargetSession: "work"}) | |
| 328 | + | ||
| 329 | + | require.True(t, result.OK, result.Text) | |
| 330 | + | sess.mu.Lock() | |
| 331 | + | require.Len(t, sess.tabs, 1) | |
| 332 | + | require.Equal(t, "t_first", sess.tabs[0].stableID) | |
| 333 | + | sess.mu.Unlock() | |
| 334 | + | }) | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | func TestHandleCommandHeadlessMutations(t *testing.T) { | |
| 338 | + | tests := []struct { | |
| 339 | + | name string | |
| 340 | + | slug string | |
| 341 | + | args []string | |
| 342 | + | verify func(*testing.T, *Daemon, *session) | |
| 343 | + | }{ | |
| 344 | + | {name: "split focused pane", slug: "split-right", verify: func(t *testing.T, _ *Daemon, sess *session) { | |
| 345 | + | tb := sess.activeTab() | |
| 346 | + | tb.mu.Lock() | |
| 347 | + | defer tb.mu.Unlock() | |
| 348 | + | require.Len(t, tb.panes, 2) | |
| 349 | + | }}, | |
| 350 | + | {name: "create tab from retained viewport", slug: "new-tab", verify: func(t *testing.T, _ *Daemon, sess *session) { | |
| 351 | + | sess.mu.Lock() | |
| 352 | + | defer sess.mu.Unlock() | |
| 353 | + | require.Len(t, sess.tabs, 2) | |
| 354 | + | require.Equal(t, 1, sess.active) | |
| 355 | + | }}, | |
| 356 | + | {name: "rename tab", slug: "rename-tab", args: []string{"logs"}, verify: func(t *testing.T, _ *Daemon, sess *session) { | |
| 357 | + | tb := sess.activeTab() | |
| 358 | + | tb.mu.Lock() | |
| 359 | + | defer tb.mu.Unlock() | |
| 360 | + | require.Equal(t, "logs", tb.name) | |
| 361 | + | }}, | |
| 362 | + | } | |
| 363 | + | for _, tt := range tests { | |
| 364 | + | t.Run(tt.name, func(t *testing.T) { | |
| 365 | + | factory := &controlPTYFactory{} | |
| 366 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 367 | + | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |
| 368 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 369 | + | ||
| 370 | + | result := sendCommand(t, d, ports.CommandRequest{Slug: tt.slug, Args: tt.args, TargetSession: "work"}) | |
| 371 | + | ||
| 372 | + | require.True(t, result.OK, result.Text) | |
| 373 | + | tt.verify(t, d, sess) | |
| 374 | + | }) | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T) { | |
| 379 | + | factory := &controlPTYFactory{} | |
| 380 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 381 | + | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |
| 382 | + | source := addControlSession(d, "work", "t_work", "p_work") | |
| 383 | + | source.mu.Lock() | |
| 384 | + | source.cwd = "/tmp/work" | |
| 385 | + | source.env = []string{"INHERITED=yes"} | |
| 386 | + | source.terminal = terminalEnv{TrueColor: true} | |
| 387 | + | source.tabs[0].size = domain.Size{Cols: 118, Rows: 38} | |
| 388 | + | source.mu.Unlock() | |
| 389 | + | ||
| 390 | + | result := sendCommand(t, d, ports.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"}) | |
| 391 | + | ||
| 392 | + | require.True(t, result.OK, result.Text) | |
| 393 | + | d.mu.Lock() | |
| 394 | + | created := d.findByNameLocked("scripted") | |
| 395 | + | d.mu.Unlock() | |
| 396 | + | require.NotNil(t, created) | |
| 397 | + | created.mu.Lock() | |
| 398 | + | require.Nil(t, created.client) | |
| 399 | + | require.Equal(t, "/tmp/work", created.cwd) | |
| 400 | + | require.Equal(t, []string{"INHERITED=yes"}, created.env) | |
| 401 | + | require.True(t, created.terminal.TrueColor) | |
| 402 | + | tb := created.tabs[0] | |
| 403 | + | created.mu.Unlock() | |
| 404 | + | tb.mu.Lock() | |
| 405 | + | require.Equal(t, domain.Size{Cols: 118, Rows: 38}, tb.size) | |
| 406 | + | tb.mu.Unlock() | |
| 407 | + | require.Nil(t, source.client) | |
| 408 | + | ||
| 409 | + | taken := sendCommand(t, d, ports.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"}) | |
| 410 | + | require.False(t, taken.OK) | |
| 411 | + | require.Equal(t, ports.ErrNameTaken, taken.Code) | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | func TestHandleCommandValidatesToastAndQueuesForDetachedSession(t *testing.T) { | |
| 415 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 416 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 417 | + | ||
| 418 | + | bad := sendCommand(t, d, ports.CommandRequest{Slug: "toast", Args: []string{"-l", "loud", "hello"}, TargetSession: "work"}) | |
| 419 | + | require.False(t, bad.OK) | |
| 420 | + | require.Equal(t, ports.ErrInvalidCommandArgs, bad.Code) | |
| 421 | + | ||
| 422 | + | good := sendCommand(t, d, ports.CommandRequest{Slug: "toast", Args: []string{"-l", "warn", "hello"}, TargetSession: "work"}) | |
| 423 | + | require.True(t, good.OK, good.Text) | |
| 424 | + | d.notices.mu.Lock() | |
| 425 | + | require.Len(t, d.notices.pending, 1) | |
| 426 | + | require.Equal(t, domain.NoticeUser, d.notices.pending[0].Code) | |
| 427 | + | require.Equal(t, sess.id, d.notices.pending[0].SessionID) | |
| 428 | + | d.notices.mu.Unlock() | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) { | |
| 432 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 433 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 434 | + | sess.tabs[0].name = "shell" | |
| 435 | + | ||
| 436 | + | sessions := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"}) | |
| 437 | + | require.True(t, sessions.OK, sessions.Text) | |
| 438 | + | require.Contains(t, sessions.Output, "NAME\tSTATE\tTABS\tATTACHED\tACTIVE") | |
| 439 | + | require.Contains(t, sessions.Output, "work\tephemeral\t1\tfalse\ttrue") | |
| 440 | + | ||
| 441 | + | tabs := sendCommand(t, d, ports.CommandRequest{Slug: "list-tabs", TargetSession: "work"}) | |
| 442 | + | require.True(t, tabs.OK, tabs.Text) | |
| 443 | + | require.Contains(t, tabs.Output, "0\tt_work\tshell\t1\ttrue") | |
| 444 | + | ||
| 445 | + | panes := sendCommand(t, d, ports.CommandRequest{Slug: "list-panes", TargetSession: "work", JSON: true}) | |
| 446 | + | require.True(t, panes.OK, panes.Text) | |
| 447 | + | var decoded []map[string]any | |
| 448 | + | require.NoError(t, json.Unmarshal([]byte(panes.Output), &decoded)) | |
| 449 | + | require.Len(t, decoded, 1) | |
| 450 | + | require.Equal(t, "p_work", decoded[0]["id"]) | |
| 451 | + | require.Equal(t, "/tmp/work", decoded[0]["cwd"]) | |
| 452 | + | require.Equal(t, true, decoded[0]["focused"]) | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) { | |
| 456 | + | factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})} | |
| 457 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 458 | + | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |
| 459 | + | sess := addControlSession(d, "work", "t_active", "p_active") | |
| 460 | + | target := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 461 | + | target.ctx, target.cancel = context.WithCancel(d.serveCtx) | |
| 462 | + | sess.mu.Lock() | |
| 463 | + | sess.tabs = append(sess.tabs, target) | |
| 464 | + | sess.active = 0 | |
| 465 | + | sess.mu.Unlock() | |
| 466 | + | original := target.tree.Focus | |
| 467 | + | ||
| 468 | + | firstFrame := commandFrame(t, ports.CommandRequest{ | |
| 469 | + | Slug: "split-right", Self: true, TargetSession: "work", | |
| 470 | + | TargetTab: "t_invoking", TargetPane: "p_invoking", | |
| 471 | + | }) | |
| 472 | + | firstTransport, firstSends, _ := newConn(t, firstFrame) | |
| 473 | + | firstDone := make(chan struct{}) | |
| 474 | + | go func() { | |
| 475 | + | d.handleCommand(firstTransport, firstFrame) | |
| 476 | + | close(firstDone) | |
| 477 | + | }() | |
| 478 | + | select { | |
| 479 | + | case <-factory.entered: | |
| 480 | + | case <-time.After(2 * time.Second): | |
| 481 | + | t.Fatal("first command did not enter its blocked action") | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | secondFrame := commandFrame(t, ports.CommandRequest{ | |
| 485 | + | Slug: "focus-pane-right", Self: true, TargetSession: "work", | |
| 486 | + | TargetTab: "t_invoking", TargetPane: "p_invoking", | |
| 487 | + | }) | |
| 488 | + | secondTransport, secondSends, _ := newConn(t, secondFrame) | |
| 489 | + | secondDone := make(chan struct{}) | |
| 490 | + | go func() { | |
| 491 | + | d.handleCommand(secondTransport, secondFrame) | |
| 492 | + | close(secondDone) | |
| 493 | + | }() | |
| 494 | + | select { | |
| 495 | + | case <-secondDone: | |
| 496 | + | t.Fatal("second command bypassed serialization") | |
| 497 | + | case <-time.After(100 * time.Millisecond): | |
| 498 | + | } | |
| 499 | + | close(factory.release) | |
| 500 | + | <-firstDone | |
| 501 | + | <-secondDone | |
| 502 | + | require.True(t, awaitCommandResult(t, firstSends).OK) | |
| 503 | + | require.True(t, awaitCommandResult(t, secondSends).OK) | |
| 504 | + | ||
| 505 | + | sess.mu.Lock() | |
| 506 | + | require.Zero(t, sess.active, "self commands must not select the invoking tab") | |
| 507 | + | sess.mu.Unlock() | |
| 508 | + | target.mu.Lock() | |
| 509 | + | defer target.mu.Unlock() | |
| 510 | + | require.Len(t, target.panes, 2) | |
| 511 | + | require.NotEqual(t, original, target.tree.Focus, "second command must execute after the split creates its right neighbor") | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | func commandFrame(t *testing.T, request ports.CommandRequest) ports.Frame { | |
| 515 | + | t.Helper() | |
| 516 | + | if request.Version == 0 { | |
| 517 | + | request.Version = ports.ProtocolVersion | |
| 518 | + | } | |
| 519 | + | payload, err := ports.MarshalCommandRequest(request) | |
| 520 | + | require.NoError(t, err) | |
| 521 | + | return ports.Frame{Type: ports.MsgCommand, Payload: payload} | |
| 522 | + | } | |
| 523 | + | ||
| 524 | + | func sendCommand(t *testing.T, d *Daemon, request ports.CommandRequest) ports.CommandResult { | |
| 525 | + | t.Helper() | |
| 526 | + | frame := commandFrame(t, request) | |
| 527 | + | tr, sends, _ := newConn(t, frame) | |
| 528 | + | d.handleCommand(tr, frame) | |
| 529 | + | return awaitCommandResult(t, sends) | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | func awaitCommandResult(t *testing.T, sends chan ports.Frame) ports.CommandResult { | |
| 533 | + | t.Helper() | |
| 534 | + | reply := awaitFrame(t, sends, ports.MsgCommandResult) | |
| 535 | + | result, err := ports.UnmarshalCommandResult(reply.Payload) | |
| 536 | + | require.NoError(t, err) | |
| 537 | + | return result | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | type controlPTYFactory struct { | |
| 541 | + | mu sync.Mutex | |
| 542 | + | ptys []*quietPTY | |
| 543 | + | entered chan struct{} | |
| 544 | + | release chan struct{} | |
| 545 | + | once sync.Once | |
| 546 | + | } | |
| 547 | + | ||
| 548 | + | func (f *controlPTYFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) { | |
| 549 | + | f.once.Do(func() { | |
| 550 | + | if f.entered != nil { | |
| 551 | + | close(f.entered) | |
| 552 | + | <-f.release | |
| 553 | + | } | |
| 554 | + | }) | |
| 555 | + | pty := newQuietPTY() | |
| 556 | + | f.mu.Lock() | |
| 557 | + | f.ptys = append(f.ptys, pty) | |
| 558 | + | f.mu.Unlock() | |
| 559 | + | return pty, nil | |
| 560 | + | } | |
| 561 | + | ||
| 562 | + | func (f *controlPTYFactory) close() { | |
| 563 | + | if f.release != nil { | |
| 564 | + | select { | |
| 565 | + | case <-f.release: | |
| 566 | + | default: | |
| 567 | + | close(f.release) | |
| 568 | + | } | |
| 569 | + | } | |
| 570 | + | f.mu.Lock() | |
| 571 | + | defer f.mu.Unlock() | |
| 572 | + | for _, pty := range f.ptys { | |
| 573 | + | _ = pty.Close() | |
| 574 | + | } | |
| 575 | + | } | |
| 576 | + | ||
| 577 | + | func addControlSession(d *Daemon, name, tabID, paneID string) *session { | |
| 578 | + | tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 579 | + | tb.ctx, tb.cancel = context.WithCancel(d.serveCtx) | |
| 580 | + | sess := &session{ | |
| 581 | + | id: domain.SessionID("sess-" + name), name: name, ephemeral: true, cwd: "/tmp/" + name, | |
| 582 | + | ctx: d.serveCtx, cancel: func() {}, tabs: []*tab{tb}, env: []string{"MARK=" + name}, | |
| 583 | + | } | |
| 584 | + | sess.mruAt.Store(1) | |
| 585 | + | d.mu.Lock() | |
| 586 | + | d.sessions[sess.id] = sess | |
| 587 | + | d.mu.Unlock() | |
| 588 | + | return sess | |
| 589 | + | } | |
| @@ -0,0 +1,589 @@ | |||||
| 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 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 13 | + | "github.com/bnema/vev/internal/usecase/command" | |||
| 14 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 15 | + | "github.com/stretchr/testify/require" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | func TestPaletteAndControlShareExplicitDaemonActionTarget(t *testing.T) { | |||
| 19 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 20 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |||
| 21 | + | tb := sess.activeTab() | |||
| 22 | + | tb.mu.Lock() | |||
| 23 | + | pane := tb.focusedPane() | |||
| 24 | + | tb.mu.Unlock() | |||
| 25 | + | spy := &actionRunnerSpy{} | |||
| 26 | + | ||||
| 27 | + | require.NoError(t, (paletteExec{d: d, sess: sess, actions: spy}).SplitRight()) | |||
| 28 | + | require.NoError(t, (controlExec{ | |||
| 29 | + | d: d, sess: sess, tab: tb, actions: spy, | |||
| 30 | + | target: daemonActionTarget{session: sess, tab: tb, pane: pane}, | |||
| 31 | + | }).SplitRight()) | |||
| 32 | + | ||||
| 33 | + | require.Len(t, spy.requests, 2) | |||
| 34 | + | for _, request := range spy.requests { | |||
| 35 | + | require.Equal(t, daemonActionSplitPane, request.kind) | |||
| 36 | + | require.Same(t, sess, request.target.session) | |||
| 37 | + | require.Same(t, tb, request.target.tab) | |||
| 38 | + | require.Same(t, pane, request.target.pane) | |||
| 39 | + | require.Equal(t, layout.Right, request.direction) | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | type actionRunnerSpy struct { | |||
| 44 | + | requests []daemonActionRequest | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (s *actionRunnerSpy) Run(request daemonActionRequest) error { | |||
| 48 | + | s.requests = append(s.requests, request) | |||
| 49 | + | return nil | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestHandleCommandRejectsVersionBeforeDecodeOrDispatch(t *testing.T) { | |||
| 53 | + | // The version prefix is valid and mismatched, while the remainder is not a | |||
| 54 | + | // decodable request. The daemon must still return ErrVersionMismatch. | |||
| 55 | + | frame := ports.Frame{Type: ports.MsgCommand, Payload: []byte{0, byte(ports.ProtocolVersion + 1)}} | |||
| 56 | + | tr, sends, _ := newConn(t, frame) | |||
| 57 | + | ||||
| 58 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 59 | + | d.handleCommand(tr, frame) | |||
| 60 | + | ||||
| 61 | + | result := awaitCommandResult(t, sends) | |||
| 62 | + | require.False(t, result.OK) | |||
| 63 | + | require.Equal(t, ports.ErrVersionMismatch, result.Code) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestHandleConnRoutesCommand(t *testing.T) { | |||
| 67 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 68 | + | request := ports.CommandRequest{Version: ports.ProtocolVersion, Slug: "list-sessions"} | |||
| 69 | + | payload, err := ports.MarshalCommandRequest(request) | |||
| 70 | + | require.NoError(t, err) | |||
| 71 | + | frame := ports.Frame{Type: ports.MsgCommand, Payload: payload} | |||
| 72 | + | tr, sends, _ := newConn(t, frame) | |||
| 73 | + | ||||
| 74 | + | d.handleConn(tr) | |||
| 75 | + | ||||
| 76 | + | result := awaitCommandResult(t, sends) | |||
| 77 | + | require.True(t, result.OK, result.Text) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestHandleCommandDispatchAndTargetErrors(t *testing.T) { | |||
| 81 | + | tests := []struct { | |||
| 82 | + | name string | |||
| 83 | + | arrange func(*Daemon) | |||
| 84 | + | request ports.CommandRequest | |||
| 85 | + | code uint16 | |||
| 86 | + | }{ | |||
| 87 | + | {name: "unknown slug", request: ports.CommandRequest{Slug: "no-such"}, code: ports.ErrUnknownCommand}, | |||
| 88 | + | {name: "non-scriptable command", request: ports.CommandRequest{Slug: "session-picker"}, code: ports.ErrNotScriptable}, | |||
| 89 | + | {name: "no sessions", request: ports.CommandRequest{Slug: "split-right"}, code: ports.ErrNoSuchTarget}, | |||
| 90 | + | {name: "ambiguous sessions", arrange: func(d *Daemon) { | |||
| 91 | + | addControlSession(d, "one", "t_one", "p_one") | |||
| 92 | + | addControlSession(d, "two", "t_two", "p_two") | |||
| 93 | + | }, request: ports.CommandRequest{Slug: "split-right"}, code: ports.ErrAmbiguousTarget}, | |||
| 94 | + | {name: "missing explicit session", arrange: func(d *Daemon) { | |||
| 95 | + | addControlSession(d, "work", "t_work", "p_work") | |||
| 96 | + | }, request: ports.CommandRequest{Slug: "new-tab", TargetSession: "missing"}, code: ports.ErrNoSuchTarget}, | |||
| 97 | + | {name: "missing stable IDs", arrange: func(d *Daemon) { | |||
| 98 | + | addControlSession(d, "work", "t_work", "p_work") | |||
| 99 | + | }, request: ports.CommandRequest{Slug: "split-right", TargetTab: "t_nope", TargetPane: "p_nope"}, code: ports.ErrNoSuchTarget}, | |||
| 100 | + | {name: "cross-session IDs rejected", arrange: func(d *Daemon) { | |||
| 101 | + | addControlSession(d, "one", "t_one", "p_one") | |||
| 102 | + | addControlSession(d, "two", "t_two", "p_two") | |||
| 103 | + | }, request: ports.CommandRequest{Slug: "split-right", TargetSession: "one", TargetTab: "t_two", TargetPane: "p_two"}, code: ports.ErrNoSuchTarget}, | |||
| 104 | + | {name: "self requires both IDs", arrange: func(d *Daemon) { | |||
| 105 | + | addControlSession(d, "work", "t_work", "p_work") | |||
| 106 | + | }, request: ports.CommandRequest{Slug: "split-right", Self: true, TargetSession: "work", TargetTab: "t_work"}, code: ports.ErrNoSuchTarget}, | |||
| 107 | + | {name: "self rejects pane from another tab", arrange: func(d *Daemon) { | |||
| 108 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |||
| 109 | + | other := newTabWithStableID("t_other", "p_other", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |||
| 110 | + | other.ctx, other.cancel = context.WithCancel(d.serveCtx) | |||
| 111 | + | sess.tabs = append(sess.tabs, other) | |||
| 112 | + | }, request: ports.CommandRequest{Slug: "split-right", Self: true, TargetSession: "work", TargetTab: "t_work", TargetPane: "p_other"}, code: ports.ErrNoSuchTarget}, | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | for _, tt := range tests { | |||
| 116 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 117 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 118 | + | if tt.arrange != nil { | |||
| 119 | + | tt.arrange(d) | |||
| 120 | + | } | |||
| 121 | + | result := sendCommand(t, d, tt.request) | |||
| 122 | + | require.False(t, result.OK) | |||
| 123 | + | require.Equal(t, tt.code, result.Code) | |||
| 124 | + | }) | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func TestHandleCommandResolvesStaleSessionNameByStableIDs(t *testing.T) { | |||
| 129 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 130 | + | sess := addControlSession(d, "renamed", "t_work", "p_work") | |||
| 131 | + | ||||
| 132 | + | result := sendCommand(t, d, ports.CommandRequest{ | |||
| 133 | + | Slug: "toast", Args: []string{"hello"}, | |||
| 134 | + | TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work", | |||
| 135 | + | }) | |||
| 136 | + | ||||
| 137 | + | require.True(t, result.OK, result.Text) | |||
| 138 | + | require.Equal(t, uint64(1), sess.mruAt.Load()) | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func TestHandleCommandStableIDsResolveSessionWithoutSelectingTab(t *testing.T) { | |||
| 142 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 143 | + | sess := addControlSession(d, "work", "t_first", "p_first") | |||
| 144 | + | first := sess.tabs[0] | |||
| 145 | + | second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |||
| 146 | + | second.ctx, second.cancel = context.WithCancel(d.serveCtx) | |||
| 147 | + | sess.mu.Lock() | |||
| 148 | + | sess.tabs = append(sess.tabs, second) | |||
| 149 | + | sess.active = 0 | |||
| 150 | + | sess.mu.Unlock() | |||
| 151 | + | ||||
| 152 | + | result := sendCommand(t, d, ports.CommandRequest{ | |||
| 153 | + | Slug: "rename-tab", Args: []string{"targeted"}, TargetSession: "work", | |||
| 154 | + | TargetTab: "t_second", TargetPane: "p_second", | |||
| 155 | + | }) | |||
| 156 | + | ||||
| 157 | + | require.True(t, result.OK, result.Text) | |||
| 158 | + | first.mu.Lock() | |||
| 159 | + | require.Equal(t, "targeted", first.name) | |||
| 160 | + | first.mu.Unlock() | |||
| 161 | + | second.mu.Lock() | |||
| 162 | + | require.Empty(t, second.name) | |||
| 163 | + | second.mu.Unlock() | |||
| 164 | + | sess.mu.Lock() | |||
| 165 | + | require.Zero(t, sess.active) | |||
| 166 | + | sess.mu.Unlock() | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func TestHandleCommandStableIDsDoNotRedirectSplitFromCurrentFocus(t *testing.T) { | |||
| 170 | + | factory := &controlPTYFactory{} | |||
| 171 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 172 | + | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |||
| 173 | + | sess := addControlSession(d, "work", "t_active", "p_active") | |||
| 174 | + | active := sess.tabs[0] | |||
| 175 | + | originFocus := active.tree.Focus | |||
| 176 | + | origin := active.focusedPane() | |||
| 177 | + | second := newTabWithStableID("t_origin", "p_origin", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |||
| 178 | + | second.ctx, second.cancel = context.WithCancel(d.serveCtx) | |||
| 179 | + | sess.mu.Lock() | |||
| 180 | + | sess.tabs = append(sess.tabs, second) | |||
| 181 | + | sess.active = 0 | |||
| 182 | + | sess.mu.Unlock() | |||
| 183 | + | ||||
| 184 | + | result := sendCommand(t, d, ports.CommandRequest{ | |||
| 185 | + | Slug: "split-right", TargetSession: "work", | |||
| 186 | + | TargetTab: "t_origin", TargetPane: "p_origin", | |||
| 187 | + | }) | |||
| 188 | + | ||||
| 189 | + | require.True(t, result.OK, result.Text) | |||
| 190 | + | sess.mu.Lock() | |||
| 191 | + | activeIndex := sess.active | |||
| 192 | + | sess.mu.Unlock() | |||
| 193 | + | require.Zero(t, activeIndex) | |||
| 194 | + | active.mu.Lock() | |||
| 195 | + | activePaneCount := len(active.panes) | |||
| 196 | + | activeFocus := active.tree.Focus | |||
| 197 | + | originRetained := active.panes[origin.id] == origin | |||
| 198 | + | active.mu.Unlock() | |||
| 199 | + | require.Equal(t, 2, activePaneCount, "split must mutate the daemon-focused tab") | |||
| 200 | + | require.NotEqual(t, originFocus, activeFocus, "split must focus the new pane beside the daemon-focused pane") | |||
| 201 | + | require.True(t, originRetained) | |||
| 202 | + | second.mu.Lock() | |||
| 203 | + | secondPaneCount := len(second.panes) | |||
| 204 | + | second.mu.Unlock() | |||
| 205 | + | require.Equal(t, 1, secondPaneCount, "stable IDs are only a session locator") | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) { | |||
| 209 | + | factory := &controlPTYFactory{} | |||
| 210 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 211 | + | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |||
| 212 | + | sess := addControlSession(d, "work", "t_active", "p_active") | |||
| 213 | + | active := sess.tabs[0] | |||
| 214 | + | invoking := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |||
| 215 | + | invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx) | |||
| 216 | + | sess.mu.Lock() | |||
| 217 | + | sess.tabs = append(sess.tabs, invoking) | |||
| 218 | + | sess.active = 0 | |||
| 219 | + | sess.mu.Unlock() | |||
| 220 | + | ||||
| 221 | + | rename := sendCommand(t, d, ports.CommandRequest{ | |||
| 222 | + | Slug: "rename-tab", Args: []string{"invoking"}, Self: true, | |||
| 223 | + | TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking", | |||
| 224 | + | }) | |||
| 225 | + | require.True(t, rename.OK, rename.Text) | |||
| 226 | + | active.mu.Lock() | |||
| 227 | + | require.Empty(t, active.name) | |||
| 228 | + | active.mu.Unlock() | |||
| 229 | + | invoking.mu.Lock() | |||
| 230 | + | require.Equal(t, "invoking", invoking.name) | |||
| 231 | + | invoking.mu.Unlock() | |||
| 232 | + | ||||
| 233 | + | split := sendCommand(t, d, ports.CommandRequest{ | |||
| 234 | + | Slug: "split-right", Self: true, | |||
| 235 | + | TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking", | |||
| 236 | + | }) | |||
| 237 | + | require.True(t, split.OK, split.Text) | |||
| 238 | + | active.mu.Lock() | |||
| 239 | + | require.Len(t, active.panes, 1) | |||
| 240 | + | active.mu.Unlock() | |||
| 241 | + | invoking.mu.Lock() | |||
| 242 | + | require.Len(t, invoking.panes, 2) | |||
| 243 | + | invoking.mu.Unlock() | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | func TestHandleCommandSelfListPanesUsesInvokingTab(t *testing.T) { | |||
| 247 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 248 | + | sess := addControlSession(d, "work", "t_active", "p_active") | |||
| 249 | + | invoking := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |||
| 250 | + | invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx) | |||
| 251 | + | sess.mu.Lock() | |||
| 252 | + | sess.tabs = append(sess.tabs, invoking) | |||
| 253 | + | sess.active = 0 | |||
| 254 | + | sess.mu.Unlock() | |||
| 255 | + | ||||
| 256 | + | result := sendCommand(t, d, ports.CommandRequest{ | |||
| 257 | + | Slug: "list-panes", Self: true, JSON: true, | |||
| 258 | + | TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking", | |||
| 259 | + | }) | |||
| 260 | + | require.True(t, result.OK, result.Text) | |||
| 261 | + | require.Contains(t, result.Output, "p_invoking") | |||
| 262 | + | require.NotContains(t, result.Output, "p_active") | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | func TestHandleCommandRenameSessionRejectsInvalidNameAsCommandArgs(t *testing.T) { | |||
| 266 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 267 | + | addControlSession(d, "work", "t_work", "p_work") | |||
| 268 | + | ||||
| 269 | + | result := sendCommand(t, d, ports.CommandRequest{ | |||
| 270 | + | Slug: "rename-session", Args: []string{"invalid name"}, TargetSession: "work", | |||
| 271 | + | }) | |||
| 272 | + | ||||
| 273 | + | require.False(t, result.OK) | |||
| 274 | + | require.Equal(t, ports.ErrInvalidCommandArgs, result.Code) | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | func TestCloseCommandsReportMutationOutcome(t *testing.T) { | |||
| 278 | + | t.Run("stale close tab target fails", func(t *testing.T) { | |||
| 279 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 280 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |||
| 281 | + | stale := newTabWithStableID("t_stale", "p_stale", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |||
| 282 | + | cmd, ok := command.BySlug("close-tab") | |||
| 283 | + | require.True(t, ok) | |||
| 284 | + | ||||
| 285 | + | result := d.runControl(cmd, controlExec{ | |||
| 286 | + | d: d, sess: sess, tab: stale, | |||
| 287 | + | target: daemonActionTarget{session: sess, tab: stale}, | |||
| 288 | + | }, ports.CommandRequest{Slug: "close-tab"}) | |||
| 289 | + | ||||
| 290 | + | require.False(t, result.OK) | |||
| 291 | + | require.Equal(t, ports.ErrInternal, result.Code) | |||
| 292 | + | require.Len(t, sess.tabs, 1, "a stale close must retain the live tab") | |||
| 293 | + | }) | |||
| 294 | + | ||||
| 295 | + | t.Run("failed final pane close fails and retains session", func(t *testing.T) { | |||
| 296 | + | d := newTestDaemon(t, &controlPTYFactory{}, stubClock{}) | |||
| 297 | + | d.snapsEnabled = true | |||
| 298 | + | d.snapshotRepository = portsmocks.NewMockSnapshotRepository(t) | |||
| 299 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |||
| 300 | + | sess.mu.Lock() | |||
| 301 | + | sess.ephemeral = false | |||
| 302 | + | sess.mu.Unlock() | |||
| 303 | + | sess.snapEligible.Store(true) | |||
| 304 | + | ||||
| 305 | + | result := sendCommand(t, d, ports.CommandRequest{Slug: "close-pane", TargetSession: "work"}) | |||
| 306 | + | ||||
| 307 | + | require.False(t, result.OK) | |||
| 308 | + | require.Equal(t, ports.ErrInternal, result.Code) | |||
| 309 | + | d.mu.Lock() | |||
| 310 | + | require.Same(t, sess, d.sessions[sess.id], "failed final-pane close must retain the session") | |||
| 311 | + | d.mu.Unlock() | |||
| 312 | + | sess.mu.Lock() | |||
| 313 | + | require.Len(t, sess.tabs, 1) | |||
| 314 | + | sess.mu.Unlock() | |||
| 315 | + | }) | |||
| 316 | + | ||||
| 317 | + | t.Run("successful close tab remains successful", func(t *testing.T) { | |||
| 318 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 319 | + | sess := addControlSession(d, "work", "t_first", "p_first") | |||
| 320 | + | second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |||
| 321 | + | second.ctx, second.cancel = context.WithCancel(d.serveCtx) | |||
| 322 | + | sess.mu.Lock() | |||
| 323 | + | sess.tabs = append(sess.tabs, second) | |||
| 324 | + | sess.active = 1 | |||
| 325 | + | sess.mu.Unlock() | |||
| 326 | + | ||||
| 327 | + | result := sendCommand(t, d, ports.CommandRequest{Slug: "close-tab", TargetSession: "work"}) | |||
| 328 | + | ||||
| 329 | + | require.True(t, result.OK, result.Text) | |||
| 330 | + | sess.mu.Lock() | |||
| 331 | + | require.Len(t, sess.tabs, 1) | |||
| 332 | + | require.Equal(t, "t_first", sess.tabs[0].stableID) | |||
| 333 | + | sess.mu.Unlock() | |||
| 334 | + | }) | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | func TestHandleCommandHeadlessMutations(t *testing.T) { | |||
| 338 | + | tests := []struct { | |||
| 339 | + | name string | |||
| 340 | + | slug string | |||
| 341 | + | args []string | |||
| 342 | + | verify func(*testing.T, *Daemon, *session) | |||
| 343 | + | }{ | |||
| 344 | + | {name: "split focused pane", slug: "split-right", verify: func(t *testing.T, _ *Daemon, sess *session) { | |||
| 345 | + | tb := sess.activeTab() | |||
| 346 | + | tb.mu.Lock() | |||
| 347 | + | defer tb.mu.Unlock() | |||
| 348 | + | require.Len(t, tb.panes, 2) | |||
| 349 | + | }}, | |||
| 350 | + | {name: "create tab from retained viewport", slug: "new-tab", verify: func(t *testing.T, _ *Daemon, sess *session) { | |||
| 351 | + | sess.mu.Lock() | |||
| 352 | + | defer sess.mu.Unlock() | |||
| 353 | + | require.Len(t, sess.tabs, 2) | |||
| 354 | + | require.Equal(t, 1, sess.active) | |||
| 355 | + | }}, | |||
| 356 | + | {name: "rename tab", slug: "rename-tab", args: []string{"logs"}, verify: func(t *testing.T, _ *Daemon, sess *session) { | |||
| 357 | + | tb := sess.activeTab() | |||
| 358 | + | tb.mu.Lock() | |||
| 359 | + | defer tb.mu.Unlock() | |||
| 360 | + | require.Equal(t, "logs", tb.name) | |||
| 361 | + | }}, | |||
| 362 | + | } | |||
| 363 | + | for _, tt := range tests { | |||
| 364 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 365 | + | factory := &controlPTYFactory{} | |||
| 366 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 367 | + | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |||
| 368 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |||
| 369 | + | ||||
| 370 | + | result := sendCommand(t, d, ports.CommandRequest{Slug: tt.slug, Args: tt.args, TargetSession: "work"}) | |||
| 371 | + | ||||
| 372 | + | require.True(t, result.OK, result.Text) | |||
| 373 | + | tt.verify(t, d, sess) | |||
| 374 | + | }) | |||
| 375 | + | } | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T) { | |||
| 379 | + | factory := &controlPTYFactory{} | |||
| 380 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 381 | + | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |||
| 382 | + | source := addControlSession(d, "work", "t_work", "p_work") | |||
| 383 | + | source.mu.Lock() | |||
| 384 | + | source.cwd = "/tmp/work" | |||
| 385 | + | source.env = []string{"INHERITED=yes"} | |||
| 386 | + | source.terminal = terminalEnv{TrueColor: true} | |||
| 387 | + | source.tabs[0].size = domain.Size{Cols: 118, Rows: 38} | |||
| 388 | + | source.mu.Unlock() | |||
| 389 | + | ||||
| 390 | + | result := sendCommand(t, d, ports.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"}) | |||
| 391 | + | ||||
| 392 | + | require.True(t, result.OK, result.Text) | |||
| 393 | + | d.mu.Lock() | |||
| 394 | + | created := d.findByNameLocked("scripted") | |||
| 395 | + | d.mu.Unlock() | |||
| 396 | + | require.NotNil(t, created) | |||
| 397 | + | created.mu.Lock() | |||
| 398 | + | require.Nil(t, created.client) | |||
| 399 | + | require.Equal(t, "/tmp/work", created.cwd) | |||
| 400 | + | require.Equal(t, []string{"INHERITED=yes"}, created.env) | |||
| 401 | + | require.True(t, created.terminal.TrueColor) | |||
| 402 | + | tb := created.tabs[0] | |||
| 403 | + | created.mu.Unlock() | |||
| 404 | + | tb.mu.Lock() | |||
| 405 | + | require.Equal(t, domain.Size{Cols: 118, Rows: 38}, tb.size) | |||
| 406 | + | tb.mu.Unlock() | |||
| 407 | + | require.Nil(t, source.client) | |||
| 408 | + | ||||
| 409 | + | taken := sendCommand(t, d, ports.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"}) | |||
| 410 | + | require.False(t, taken.OK) | |||
| 411 | + | require.Equal(t, ports.ErrNameTaken, taken.Code) | |||
| 412 | + | } | |||
| 413 | + | ||||
| 414 | + | func TestHandleCommandValidatesToastAndQueuesForDetachedSession(t *testing.T) { | |||
| 415 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 416 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |||
| 417 | + | ||||
| 418 | + | bad := sendCommand(t, d, ports.CommandRequest{Slug: "toast", Args: []string{"-l", "loud", "hello"}, TargetSession: "work"}) | |||
| 419 | + | require.False(t, bad.OK) | |||
| 420 | + | require.Equal(t, ports.ErrInvalidCommandArgs, bad.Code) | |||
| 421 | + | ||||
| 422 | + | good := sendCommand(t, d, ports.CommandRequest{Slug: "toast", Args: []string{"-l", "warn", "hello"}, TargetSession: "work"}) | |||
| 423 | + | require.True(t, good.OK, good.Text) | |||
| 424 | + | d.notices.mu.Lock() | |||
| 425 | + | require.Len(t, d.notices.pending, 1) | |||
| 426 | + | require.Equal(t, domain.NoticeUser, d.notices.pending[0].Code) | |||
| 427 | + | require.Equal(t, sess.id, d.notices.pending[0].SessionID) | |||
| 428 | + | d.notices.mu.Unlock() | |||
| 429 | + | } | |||
| 430 | + | ||||
| 431 | + | func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) { | |||
| 432 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 433 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |||
| 434 | + | sess.tabs[0].name = "shell" | |||
| 435 | + | ||||
| 436 | + | sessions := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"}) | |||
| 437 | + | require.True(t, sessions.OK, sessions.Text) | |||
| 438 | + | require.Contains(t, sessions.Output, "NAME\tSTATE\tTABS\tATTACHED\tACTIVE") | |||
| 439 | + | require.Contains(t, sessions.Output, "work\tephemeral\t1\tfalse\ttrue") | |||
| 440 | + | ||||
| 441 | + | tabs := sendCommand(t, d, ports.CommandRequest{Slug: "list-tabs", TargetSession: "work"}) | |||
| 442 | + | require.True(t, tabs.OK, tabs.Text) | |||
| 443 | + | require.Contains(t, tabs.Output, "0\tt_work\tshell\t1\ttrue") | |||
| 444 | + | ||||
| 445 | + | panes := sendCommand(t, d, ports.CommandRequest{Slug: "list-panes", TargetSession: "work", JSON: true}) | |||
| 446 | + | require.True(t, panes.OK, panes.Text) | |||
| 447 | + | var decoded []map[string]any | |||
| 448 | + | require.NoError(t, json.Unmarshal([]byte(panes.Output), &decoded)) | |||
| 449 | + | require.Len(t, decoded, 1) | |||
| 450 | + | require.Equal(t, "p_work", decoded[0]["id"]) | |||
| 451 | + | require.Equal(t, "/tmp/work", decoded[0]["cwd"]) | |||
| 452 | + | require.Equal(t, true, decoded[0]["focused"]) | |||
| 453 | + | } | |||
| 454 | + | ||||
| 455 | + | func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) { | |||
| 456 | + | factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})} | |||
| 457 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 458 | + | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |||
| 459 | + | sess := addControlSession(d, "work", "t_active", "p_active") | |||
| 460 | + | target := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |||
| 461 | + | target.ctx, target.cancel = context.WithCancel(d.serveCtx) | |||
| 462 | + | sess.mu.Lock() | |||
| 463 | + | sess.tabs = append(sess.tabs, target) | |||
| 464 | + | sess.active = 0 | |||
| 465 | + | sess.mu.Unlock() | |||
| 466 | + | original := target.tree.Focus | |||
| 467 | + | ||||
| 468 | + | firstFrame := commandFrame(t, ports.CommandRequest{ | |||
| 469 | + | Slug: "split-right", Self: true, TargetSession: "work", | |||
| 470 | + | TargetTab: "t_invoking", TargetPane: "p_invoking", | |||
| 471 | + | }) | |||
| 472 | + | firstTransport, firstSends, _ := newConn(t, firstFrame) | |||
| 473 | + | firstDone := make(chan struct{}) | |||
| 474 | + | go func() { | |||
| 475 | + | d.handleCommand(firstTransport, firstFrame) | |||
| 476 | + | close(firstDone) | |||
| 477 | + | }() | |||
| 478 | + | select { | |||
| 479 | + | case <-factory.entered: | |||
| 480 | + | case <-time.After(2 * time.Second): | |||
| 481 | + | t.Fatal("first command did not enter its blocked action") | |||
| 482 | + | } | |||
| 483 | + | ||||
| 484 | + | secondFrame := commandFrame(t, ports.CommandRequest{ | |||
| 485 | + | Slug: "focus-pane-right", Self: true, TargetSession: "work", | |||
| 486 | + | TargetTab: "t_invoking", TargetPane: "p_invoking", | |||
| 487 | + | }) | |||
| 488 | + | secondTransport, secondSends, _ := newConn(t, secondFrame) | |||
| 489 | + | secondDone := make(chan struct{}) | |||
| 490 | + | go func() { | |||
| 491 | + | d.handleCommand(secondTransport, secondFrame) | |||
| 492 | + | close(secondDone) | |||
| 493 | + | }() | |||
| 494 | + | select { | |||
| 495 | + | case <-secondDone: | |||
| 496 | + | t.Fatal("second command bypassed serialization") | |||
| 497 | + | case <-time.After(100 * time.Millisecond): | |||
| 498 | + | } | |||
| 499 | + | close(factory.release) | |||
| 500 | + | <-firstDone | |||
| 501 | + | <-secondDone | |||
| 502 | + | require.True(t, awaitCommandResult(t, firstSends).OK) | |||
| 503 | + | require.True(t, awaitCommandResult(t, secondSends).OK) | |||
| 504 | + | ||||
| 505 | + | sess.mu.Lock() | |||
| 506 | + | require.Zero(t, sess.active, "self commands must not select the invoking tab") | |||
| 507 | + | sess.mu.Unlock() | |||
| 508 | + | target.mu.Lock() | |||
| 509 | + | defer target.mu.Unlock() | |||
| 510 | + | require.Len(t, target.panes, 2) | |||
| 511 | + | require.NotEqual(t, original, target.tree.Focus, "second command must execute after the split creates its right neighbor") | |||
| 512 | + | } | |||
| 513 | + | ||||
| 514 | + | func commandFrame(t *testing.T, request ports.CommandRequest) ports.Frame { | |||
| 515 | + | t.Helper() | |||
| 516 | + | if request.Version == 0 { | |||
| 517 | + | request.Version = ports.ProtocolVersion | |||
| 518 | + | } | |||
| 519 | + | payload, err := ports.MarshalCommandRequest(request) | |||
| 520 | + | require.NoError(t, err) | |||
| 521 | + | return ports.Frame{Type: ports.MsgCommand, Payload: payload} | |||
| 522 | + | } | |||
| 523 | + | ||||
| 524 | + | func sendCommand(t *testing.T, d *Daemon, request ports.CommandRequest) ports.CommandResult { | |||
| 525 | + | t.Helper() | |||
| 526 | + | frame := commandFrame(t, request) | |||
| 527 | + | tr, sends, _ := newConn(t, frame) | |||
| 528 | + | d.handleCommand(tr, frame) | |||
| 529 | + | return awaitCommandResult(t, sends) | |||
| 530 | + | } | |||
| 531 | + | ||||
| 532 | + | func awaitCommandResult(t *testing.T, sends chan ports.Frame) ports.CommandResult { | |||
| 533 | + | t.Helper() | |||
| 534 | + | reply := awaitFrame(t, sends, ports.MsgCommandResult) | |||
| 535 | + | result, err := ports.UnmarshalCommandResult(reply.Payload) | |||
| 536 | + | require.NoError(t, err) | |||
| 537 | + | return result | |||
| 538 | + | } | |||
| 539 | + | ||||
| 540 | + | type controlPTYFactory struct { | |||
| 541 | + | mu sync.Mutex | |||
| 542 | + | ptys []*quietPTY | |||
| 543 | + | entered chan struct{} | |||
| 544 | + | release chan struct{} | |||
| 545 | + | once sync.Once | |||
| 546 | + | } | |||
| 547 | + | ||||
| 548 | + | func (f *controlPTYFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) { | |||
| 549 | + | f.once.Do(func() { | |||
| 550 | + | if f.entered != nil { | |||
| 551 | + | close(f.entered) | |||
| 552 | + | <-f.release | |||
| 553 | + | } | |||
| 554 | + | }) | |||
| 555 | + | pty := newQuietPTY() | |||
| 556 | + | f.mu.Lock() | |||
| 557 | + | f.ptys = append(f.ptys, pty) | |||
| 558 | + | f.mu.Unlock() | |||
| 559 | + | return pty, nil | |||
| 560 | + | } | |||
| 561 | + | ||||
| 562 | + | func (f *controlPTYFactory) close() { | |||
| 563 | + | if f.release != nil { | |||
| 564 | + | select { | |||
| 565 | + | case <-f.release: | |||
| 566 | + | default: | |||
| 567 | + | close(f.release) | |||
| 568 | + | } | |||
| 569 | + | } | |||
| 570 | + | f.mu.Lock() | |||
| 571 | + | defer f.mu.Unlock() | |||
| 572 | + | for _, pty := range f.ptys { | |||
| 573 | + | _ = pty.Close() | |||
| 574 | + | } | |||
| 575 | + | } | |||
| 576 | + | ||||
| 577 | + | func addControlSession(d *Daemon, name, tabID, paneID string) *session { | |||
| 578 | + | tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |||
| 579 | + | tb.ctx, tb.cancel = context.WithCancel(d.serveCtx) | |||
| 580 | + | sess := &session{ | |||
| 581 | + | id: domain.SessionID("sess-" + name), name: name, ephemeral: true, cwd: "/tmp/" + name, | |||
| 582 | + | ctx: d.serveCtx, cancel: func() {}, tabs: []*tab{tb}, env: []string{"MARK=" + name}, | |||
| 583 | + | } | |||
| 584 | + | sess.mruAt.Store(1) | |||
| 585 | + | d.mu.Lock() | |||
| 586 | + | d.sessions[sess.id] = sess | |||
| 587 | + | d.mu.Unlock() | |||
| 588 | + | return sess | |||
| 589 | + | } | |||
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: | ||
M internal/usecase/daemon/input_test.go +1 -1
| @@ -157,7 +157,7 @@ func TestCloseTabPrunesAttachedCaptureFrames(t *testing.T) { | |||
| 157 | 157 | survivor: cachedSurvivor, | |
| 158 | 158 | } | |
| 159 | 159 | ||
| 160 | - | d.closeTab(sess, closedTab, false) | |
| 160 | + | require.NoError(t, d.closeTab(sess, closedTab, false)) | |
| 161 | 161 | require.NotContains(t, ac.captureFrames, closed, "closed tab panes must not remain strongly retained by its attachment") | |
| 162 | 162 | require.Equal(t, cachedSurvivor, ac.captureFrames[survivor], "surviving tab pane keeps its incremental capture") | |
| 163 | 163 | } | |
| @@ -157,7 +157,7 @@ func TestCloseTabPrunesAttachedCaptureFrames(t *testing.T) { | |||||
| 157 | survivor: cachedSurvivor, | 157 | survivor: cachedSurvivor, | ||
| 158 | } | 158 | } | ||
| 159 | 159 | ||||
| 160 | - | d.closeTab(sess, closedTab, false) | 160 | + | require.NoError(t, d.closeTab(sess, closedTab, false)) |
| 161 | require.NotContains(t, ac.captureFrames, closed, "closed tab panes must not remain strongly retained by its attachment") | 161 | require.NotContains(t, ac.captureFrames, closed, "closed tab panes must not remain strongly retained by its attachment") | ||
| 162 | require.Equal(t, cachedSurvivor, ac.captureFrames[survivor], "surviving tab pane keeps its incremental capture") | 162 | require.Equal(t, cachedSurvivor, ac.captureFrames[survivor], "surviving tab pane keeps its incremental capture") | ||
| 163 | } | 163 | } | ||
M internal/usecase/daemon/nil_pty_test.go +1 -1
| @@ -36,7 +36,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) { | |||
| 36 | 36 | }) | |
| 37 | 37 | require.NotContains(t, first.panes, layout.PaneID("pane-2")) | |
| 38 | 38 | ||
| 39 | - | require.NotPanics(t, func() { d.closeTab(sess, second, false) }) | |
| 39 | + | require.NotPanics(t, func() { require.NoError(t, d.closeTab(sess, second, false)) }) | |
| 40 | 40 | require.Len(t, sess.tabs, 1) | |
| 41 | 41 | } | |
| 42 | 42 | ||
| @@ -36,7 +36,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) { | |||||
| 36 | }) | 36 | }) | ||
| 37 | require.NotContains(t, first.panes, layout.PaneID("pane-2")) | 37 | require.NotContains(t, first.panes, layout.PaneID("pane-2")) | ||
| 38 | 38 | ||||
| 39 | - | require.NotPanics(t, func() { d.closeTab(sess, second, false) }) | 39 | + | require.NotPanics(t, func() { require.NoError(t, d.closeTab(sess, second, false)) }) |
| 40 | require.Len(t, sess.tabs, 1) | 40 | require.Len(t, sess.tabs, 1) | ||
| 41 | } | 41 | } | ||
| 42 | 42 | ||||
M internal/usecase/daemon/notify.go +103 -39
| @@ -25,13 +25,13 @@ const ( | |||
| 25 | 25 | var errNoNeighbor = errors.New("no pane in that direction") | |
| 26 | 26 | ||
| 27 | 27 | // noticeCenter owns the daemon-wide notification history and the queue of | |
| 28 | - | // global notices awaiting a first attached client. Its mu is leaf-level: no | |
| 29 | - | // other lock is ever taken while holding it. | |
| 28 | + | // global or session-scoped notices awaiting an eligible attached client. Its | |
| 29 | + | // mu is leaf-level: no other lock is ever taken while holding it. | |
| 30 | 30 | type noticeCenter struct { | |
| 31 | 31 | mu sync.Mutex | |
| 32 | - | // routingMu serializes global deliver-or-queue with the first-paint drain. | |
| 33 | - | // Attachment removal also takes it, so a client selected for a global toast | |
| 34 | - | // remains current until that toast is published. | |
| 32 | + | // routingMu serializes notice deliver-or-queue with the first-paint drain. | |
| 33 | + | // Attachment removal also takes it, so a client selected for a toast remains | |
| 34 | + | // current until that toast is published. | |
| 35 | 35 | routingMu sync.Mutex | |
| 36 | 36 | ring []domain.Notification | |
| 37 | 37 | pending []domain.Notification | |
| @@ -41,6 +41,9 @@ type noticeCenter struct { | |||
| 41 | 41 | // beforeGlobalDelivery is a deterministic test seam after targets have been | |
| 42 | 42 | // selected and before their toast stacks are touched. | |
| 43 | 43 | beforeGlobalDelivery func() | |
| 44 | + | // beforeSessionDelivery is a deterministic test seam after a session client | |
| 45 | + | // has been selected and before its toast stack is touched. | |
| 46 | + | beforeSessionDelivery func() | |
| 44 | 47 | // beforeClientNoticeMutation is a deterministic test seam after a client | |
| 45 | 48 | // notice's ownership validation and before its toast state is changed. | |
| 46 | 49 | beforeClientNoticeMutation func() | |
| @@ -112,7 +115,7 @@ func (nc *noticeCenter) queueGlobal(n domain.Notification) { | |||
| 112 | 115 | nc.mu.Lock() | |
| 113 | 116 | defer nc.mu.Unlock() | |
| 114 | 117 | for i := range nc.pending { | |
| 115 | - | if nc.pending[i].Code == n.Code { | |
| 118 | + | if sameToastNotice(nc.pending[i], n) { | |
| 116 | 119 | nc.pending[i].Count += n.Count | |
| 117 | 120 | nc.pending[i].Time = n.Time | |
| 118 | 121 | return | |
| @@ -223,12 +226,13 @@ func (d *Daemon) reportError(sess *session, err error) { | |||
| 223 | 226 | ||
| 224 | 227 | // notify records a notice and routes it to whoever can see it. A nil sess means | |
| 225 | 228 | // daemon-global: every attached client gets it, and if none is attached the | |
| 226 | - | // notice waits in the pending queue for the next attach. | |
| 229 | + | // notice waits in the pending queue for the next attach. A detached session's | |
| 230 | + | // notice waits for that session's next attach. | |
| 227 | 231 | // | |
| 228 | 232 | // Locking: sess.mu and d.mu are only held to snapshot; showToast is always | |
| 229 | - | // called with no daemon, session, or pane lock held. Global routing holds the | |
| 230 | - | // notice center's routingMu across selection and publication, which serializes | |
| 231 | - | // it with attachment detach and pending-queue drains. | |
| 233 | + | // called with no daemon, session, or pane lock held. Routing holds the notice | |
| 234 | + | // center's routingMu across selection and publication, which serializes it with | |
| 235 | + | // attachment detach and pending-queue drains. The order is routingMu -> sess.mu. | |
| 232 | 236 | func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) { | |
| 233 | 237 | n := domain.Notification{ | |
| 234 | 238 | Code: code, | |
| @@ -244,11 +248,24 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No | |||
| 244 | 248 | d.log.Log(d.serveCtx, slogLevelFor(sev), "user notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause) | |
| 245 | 249 | ||
| 246 | 250 | if sess != nil { | |
| 251 | + | d.notices.routingMu.Lock() | |
| 247 | 252 | sess.mu.Lock() | |
| 248 | 253 | ac := sess.client | |
| 249 | 254 | sess.mu.Unlock() | |
| 250 | - | if ac != nil { | |
| 251 | - | d.showToast(ac, n) | |
| 255 | + | if ac == nil { | |
| 256 | + | d.notices.queueGlobal(n) | |
| 257 | + | d.notices.routingMu.Unlock() | |
| 258 | + | return | |
| 259 | + | } | |
| 260 | + | if d.notices.beforeSessionDelivery != nil { | |
| 261 | + | d.notices.beforeSessionDelivery() | |
| 262 | + | } | |
| 263 | + | // Publish the toast while detach is excluded, then release routing before | |
| 264 | + | // repainting. Rendering can report another notice and re-enter routing. | |
| 265 | + | published := d.publishToast(ac, n) | |
| 266 | + | d.notices.routingMu.Unlock() | |
| 267 | + | if published { | |
| 268 | + | d.repaintForNotice(ac) | |
| 252 | 269 | } | |
| 253 | 270 | return | |
| 254 | 271 | } | |
| @@ -257,10 +274,9 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No | |||
| 257 | 274 | } | |
| 258 | 275 | ||
| 259 | 276 | // deliverGlobal atomically selects all current attachments and either publishes | |
| 260 | - | // to all of them or queues the notice. routingMu is deliberately held through | |
| 261 | - | // showToast, but never with d.mu or sess.mu held: detach cannot turn a selected | |
| 262 | - | // target stale between selection and publication, and firstPaint cannot drain | |
| 263 | - | // before an unattached route has queued its notice. | |
| 277 | + | // to all of them or queues the notice. routingMu is held through toast-state | |
| 278 | + | // publication, but never through repaint: detach cannot turn a selected target | |
| 279 | + | // stale before publication, while rendering remains free to re-enter routing. | |
| 264 | 280 | func (d *Daemon) deliverGlobal(n domain.Notification) { | |
| 265 | 281 | d.mu.Lock() | |
| 266 | 282 | d.notices.routingMu.Lock() | |
| @@ -269,45 +285,62 @@ func (d *Daemon) deliverGlobal(n domain.Notification) { | |||
| 269 | 285 | sessions = append(sessions, s) | |
| 270 | 286 | } | |
| 271 | 287 | d.mu.Unlock() | |
| 272 | - | defer d.notices.routingMu.Unlock() | |
| 273 | 288 | ||
| 274 | 289 | if d.notices.beforeGlobalDelivery != nil { | |
| 275 | 290 | d.notices.beforeGlobalDelivery() | |
| 276 | 291 | } | |
| 277 | - | delivered := false | |
| 292 | + | targets := make([]*attachedClient, 0, len(sessions)) | |
| 278 | 293 | for _, s := range sessions { | |
| 279 | 294 | s.mu.Lock() | |
| 280 | 295 | ac := s.client | |
| 281 | 296 | s.mu.Unlock() | |
| 282 | - | if ac == nil { | |
| 297 | + | if ac == nil || !d.publishToast(ac, n) { | |
| 283 | 298 | continue | |
| 284 | 299 | } | |
| 285 | - | d.showToast(ac, n) | |
| 286 | - | delivered = true | |
| 300 | + | targets = append(targets, ac) | |
| 287 | 301 | } | |
| 288 | - | if !delivered { | |
| 302 | + | if len(targets) == 0 { | |
| 289 | 303 | if d.notices.beforeQueueGlobal != nil { | |
| 290 | 304 | d.notices.beforeQueueGlobal() | |
| 291 | 305 | } | |
| 292 | 306 | d.notices.queueGlobal(n) | |
| 293 | 307 | } | |
| 308 | + | d.notices.routingMu.Unlock() | |
| 309 | + | ||
| 310 | + | for _, ac := range targets { | |
| 311 | + | d.repaintForNotice(ac) | |
| 312 | + | } | |
| 294 | 313 | } | |
| 295 | 314 | ||
| 296 | - | // drainPendingForFirstPaint publishes pending globals only while ac remains the | |
| 297 | - | // session's current attachment. It shares routingMu with deliverGlobal, so an | |
| 298 | - | // unattached global route cannot queue after this drain has already happened. | |
| 315 | + | // drainPendingForFirstPaint publishes pending globals and notices scoped to | |
| 316 | + | // sess only while ac remains the session's current attachment. It shares | |
| 317 | + | // routingMu with notice routing, so an unattached route cannot queue after this | |
| 318 | + | // drain has already happened. | |
| 299 | 319 | func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) { | |
| 300 | 320 | d.notices.routingMu.Lock() | |
| 301 | - | defer d.notices.routingMu.Unlock() | |
| 302 | 321 | ||
| 303 | 322 | sess.mu.Lock() | |
| 304 | 323 | current := sess.client == ac | |
| 305 | 324 | sess.mu.Unlock() | |
| 306 | 325 | if !current { | |
| 326 | + | d.notices.routingMu.Unlock() | |
| 307 | 327 | return | |
| 308 | 328 | } | |
| 329 | + | published := false | |
| 330 | + | var keep []domain.Notification | |
| 309 | 331 | for _, n := range d.notices.drainPending() { | |
| 310 | - | d.showToast(ac, n) | |
| 332 | + | if n.SessionID == "" || n.SessionID == sess.id { | |
| 333 | + | published = d.publishToast(ac, n) || published | |
| 334 | + | continue | |
| 335 | + | } | |
| 336 | + | keep = append(keep, n) | |
| 337 | + | } | |
| 338 | + | for _, n := range keep { | |
| 339 | + | d.notices.queueGlobal(n) | |
| 340 | + | } | |
| 341 | + | d.notices.routingMu.Unlock() | |
| 342 | + | if published { | |
| 343 | + | d.repaintForNotice(ac) | |
| 311 | 344 | } | |
| 312 | 345 | } | |
| 313 | 346 | ||
| @@ -316,27 +349,32 @@ func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode, | |||
| 316 | 349 | d.notify(nil, sev, code, msg, cause) | |
| 317 | 350 | } | |
| 318 | 351 | ||
| 319 | - | // showToast publishes a notice into one client's toast stack. An identical | |
| 320 | - | // code within the same scope coalesces into the existing entry and restarts its | |
| 321 | - | // TTL; otherwise the notice is prepended and the stack trimmed. The repaint is | |
| 322 | - | // issued after noticeMu is released. | |
| 352 | + | // showToast publishes a notice into one client's toast stack and repaints only | |
| 353 | + | // after the state mutation is complete. | |
| 323 | 354 | func (d *Daemon) showToast(ac *attachedClient, n domain.Notification) { | |
| 355 | + | if d.publishToast(ac, n) { | |
| 356 | + | d.repaintForNotice(ac) | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | // publishToast durably mutates one client's toast state without repainting. | |
| 361 | + | // Routing paths call it while routingMu excludes detach, then release routingMu | |
| 362 | + | // before rendering so a render failure can safely route another notice. | |
| 363 | + | func (d *Daemon) publishToast(ac *attachedClient, n domain.Notification) bool { | |
| 324 | 364 | if ac == nil { | |
| 325 | - | return | |
| 365 | + | return false | |
| 326 | 366 | } | |
| 327 | 367 | ac.initOverlays() | |
| 328 | 368 | rt := ac.overlays | |
| 329 | 369 | ||
| 330 | 370 | rt.noticeMu.Lock() | |
| 331 | - | if i := rt.indexOfToastLocked(n.Code, n.SessionID); i >= 0 { | |
| 371 | + | defer rt.noticeMu.Unlock() | |
| 372 | + | if i := rt.indexOfMatchingToastLocked(n); i >= 0 { | |
| 332 | 373 | rt.noticeToasts[i].n.Count += n.Count | |
| 333 | 374 | rt.noticeToasts[i].n.Time = n.Time | |
| 334 | - | rt.noticeToasts[i].n.Details = n.Details | |
| 335 | 375 | rt.noticeToasts[i].timer.stop() | |
| 336 | 376 | d.retainToastTimerLocked(ac, &rt.noticeToasts[i]) | |
| 337 | - | rt.noticeMu.Unlock() | |
| 338 | - | d.repaintForNotice(ac) | |
| 339 | - | return | |
| 377 | + | return true | |
| 340 | 378 | } | |
| 341 | 379 | ||
| 342 | 380 | toast := noticeToast{n: n} | |
| @@ -349,8 +387,34 @@ func (d *Daemon) showToast(ac *attachedClient, n domain.Notification) { | |||
| 349 | 387 | rt.noticeToasts = rt.noticeToasts[:maxVisibleToasts] | |
| 350 | 388 | } | |
| 351 | 389 | d.retainToastTimerLocked(ac, &rt.noticeToasts[0]) | |
| 352 | - | rt.noticeMu.Unlock() | |
| 353 | - | d.repaintForNotice(ac) | |
| 390 | + | return true | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | func sameToastNotice(a, b domain.Notification) bool { | |
| 394 | + | if a.Code != b.Code || a.SessionID != b.SessionID { | |
| 395 | + | return false | |
| 396 | + | } | |
| 397 | + | // NoticeUser is intentionally generic: its message and selected severity | |
| 398 | + | // are the notice identity. Typed daemon notices retain their established | |
| 399 | + | // code-and-scope coalescing behavior. | |
| 400 | + | if a.Code != domain.NoticeUser { | |
| 401 | + | return true | |
| 402 | + | } | |
| 403 | + | return a.Severity == b.Severity && | |
| 404 | + | a.Message == b.Message && | |
| 405 | + | a.Details == b.Details | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | // indexOfMatchingToastLocked finds a visible toast with the code-specific | |
| 409 | + | // coalescing identity. Count and time are occurrence metadata, not identity. | |
| 410 | + | // Callers must hold noticeMu. | |
| 411 | + | func (rt *overlayRuntime) indexOfMatchingToastLocked(n domain.Notification) int { | |
| 412 | + | for i := range rt.noticeToasts { | |
| 413 | + | if sameToastNotice(rt.noticeToasts[i].n, n) { | |
| 414 | + | return i | |
| 415 | + | } | |
| 416 | + | } | |
| 417 | + | return -1 | |
| 354 | 418 | } | |
| 355 | 419 | ||
| 356 | 420 | // indexOfToastLocked finds a visible toast with the same code and scope. | |
| @@ -25,13 +25,13 @@ const ( | |||||
| 25 | var errNoNeighbor = errors.New("no pane in that direction") | 25 | var errNoNeighbor = errors.New("no pane in that direction") | ||
| 26 | 26 | ||||
| 27 | // noticeCenter owns the daemon-wide notification history and the queue of | 27 | // noticeCenter owns the daemon-wide notification history and the queue of | ||
| 28 | - | // global notices awaiting a first attached client. Its mu is leaf-level: no | 28 | + | // global or session-scoped notices awaiting an eligible attached client. Its |
| 29 | - | // other lock is ever taken while holding it. | 29 | + | // mu is leaf-level: no other lock is ever taken while holding it. |
| 30 | type noticeCenter struct { | 30 | type noticeCenter struct { | ||
| 31 | mu sync.Mutex | 31 | mu sync.Mutex | ||
| 32 | - | // routingMu serializes global deliver-or-queue with the first-paint drain. | 32 | + | // routingMu serializes notice deliver-or-queue with the first-paint drain. |
| 33 | - | // Attachment removal also takes it, so a client selected for a global toast | 33 | + | // Attachment removal also takes it, so a client selected for a toast remains |
| 34 | - | // remains current until that toast is published. | 34 | + | // current until that toast is published. |
| 35 | routingMu sync.Mutex | 35 | routingMu sync.Mutex | ||
| 36 | ring []domain.Notification | 36 | ring []domain.Notification | ||
| 37 | pending []domain.Notification | 37 | pending []domain.Notification | ||
| @@ -41,6 +41,9 @@ type noticeCenter struct { | |||||
| 41 | // beforeGlobalDelivery is a deterministic test seam after targets have been | 41 | // beforeGlobalDelivery is a deterministic test seam after targets have been | ||
| 42 | // selected and before their toast stacks are touched. | 42 | // selected and before their toast stacks are touched. | ||
| 43 | beforeGlobalDelivery func() | 43 | beforeGlobalDelivery func() | ||
| 44 | + | // beforeSessionDelivery is a deterministic test seam after a session client | |||
| 45 | + | // has been selected and before its toast stack is touched. | |||
| 46 | + | beforeSessionDelivery func() | |||
| 44 | // beforeClientNoticeMutation is a deterministic test seam after a client | 47 | // beforeClientNoticeMutation is a deterministic test seam after a client | ||
| 45 | // notice's ownership validation and before its toast state is changed. | 48 | // notice's ownership validation and before its toast state is changed. | ||
| 46 | beforeClientNoticeMutation func() | 49 | beforeClientNoticeMutation func() | ||
| @@ -112,7 +115,7 @@ func (nc *noticeCenter) queueGlobal(n domain.Notification) { | |||||
| 112 | nc.mu.Lock() | 115 | nc.mu.Lock() | ||
| 113 | defer nc.mu.Unlock() | 116 | defer nc.mu.Unlock() | ||
| 114 | for i := range nc.pending { | 117 | for i := range nc.pending { | ||
| 115 | - | if nc.pending[i].Code == n.Code { | 118 | + | if sameToastNotice(nc.pending[i], n) { |
| 116 | nc.pending[i].Count += n.Count | 119 | nc.pending[i].Count += n.Count | ||
| 117 | nc.pending[i].Time = n.Time | 120 | nc.pending[i].Time = n.Time | ||
| 118 | return | 121 | return | ||
| @@ -223,12 +226,13 @@ func (d *Daemon) reportError(sess *session, err error) { | |||||
| 223 | 226 | ||||
| 224 | // notify records a notice and routes it to whoever can see it. A nil sess means | 227 | // notify records a notice and routes it to whoever can see it. A nil sess means | ||
| 225 | // daemon-global: every attached client gets it, and if none is attached the | 228 | // daemon-global: every attached client gets it, and if none is attached the | ||
| 226 | - | // notice waits in the pending queue for the next attach. | 229 | + | // notice waits in the pending queue for the next attach. A detached session's |
| 230 | + | // notice waits for that session's next attach. | |||
| 227 | // | 231 | // | ||
| 228 | // Locking: sess.mu and d.mu are only held to snapshot; showToast is always | 232 | // Locking: sess.mu and d.mu are only held to snapshot; showToast is always | ||
| 229 | - | // called with no daemon, session, or pane lock held. Global routing holds the | 233 | + | // called with no daemon, session, or pane lock held. Routing holds the notice |
| 230 | - | // notice center's routingMu across selection and publication, which serializes | 234 | + | // center's routingMu across selection and publication, which serializes it with |
| 231 | - | // it with attachment detach and pending-queue drains. | 235 | + | // attachment detach and pending-queue drains. The order is routingMu -> sess.mu. |
| 232 | func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) { | 236 | func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) { | ||
| 233 | n := domain.Notification{ | 237 | n := domain.Notification{ | ||
| 234 | Code: code, | 238 | Code: code, | ||
| @@ -244,11 +248,24 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No | |||||
| 244 | d.log.Log(d.serveCtx, slogLevelFor(sev), "user notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause) | 248 | d.log.Log(d.serveCtx, slogLevelFor(sev), "user notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause) | ||
| 245 | 249 | ||||
| 246 | if sess != nil { | 250 | if sess != nil { | ||
| 251 | + | d.notices.routingMu.Lock() | |||
| 247 | sess.mu.Lock() | 252 | sess.mu.Lock() | ||
| 248 | ac := sess.client | 253 | ac := sess.client | ||
| 249 | sess.mu.Unlock() | 254 | sess.mu.Unlock() | ||
| 250 | - | if ac != nil { | 255 | + | if ac == nil { |
| 251 | - | d.showToast(ac, n) | 256 | + | d.notices.queueGlobal(n) |
| 257 | + | d.notices.routingMu.Unlock() | |||
| 258 | + | return | |||
| 259 | + | } | |||
| 260 | + | if d.notices.beforeSessionDelivery != nil { | |||
| 261 | + | d.notices.beforeSessionDelivery() | |||
| 262 | + | } | |||
| 263 | + | // Publish the toast while detach is excluded, then release routing before | |||
| 264 | + | // repainting. Rendering can report another notice and re-enter routing. | |||
| 265 | + | published := d.publishToast(ac, n) | |||
| 266 | + | d.notices.routingMu.Unlock() | |||
| 267 | + | if published { | |||
| 268 | + | d.repaintForNotice(ac) | |||
| 252 | } | 269 | } | ||
| 253 | return | 270 | return | ||
| 254 | } | 271 | } | ||
| @@ -257,10 +274,9 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No | |||||
| 257 | } | 274 | } | ||
| 258 | 275 | ||||
| 259 | // deliverGlobal atomically selects all current attachments and either publishes | 276 | // deliverGlobal atomically selects all current attachments and either publishes | ||
| 260 | - | // to all of them or queues the notice. routingMu is deliberately held through | 277 | + | // to all of them or queues the notice. routingMu is held through toast-state |
| 261 | - | // showToast, but never with d.mu or sess.mu held: detach cannot turn a selected | 278 | + | // publication, but never through repaint: detach cannot turn a selected target |
| 262 | - | // target stale between selection and publication, and firstPaint cannot drain | 279 | + | // stale before publication, while rendering remains free to re-enter routing. |
| 263 | - | // before an unattached route has queued its notice. | |||
| 264 | func (d *Daemon) deliverGlobal(n domain.Notification) { | 280 | func (d *Daemon) deliverGlobal(n domain.Notification) { | ||
| 265 | d.mu.Lock() | 281 | d.mu.Lock() | ||
| 266 | d.notices.routingMu.Lock() | 282 | d.notices.routingMu.Lock() | ||
| @@ -269,45 +285,62 @@ func (d *Daemon) deliverGlobal(n domain.Notification) { | |||||
| 269 | sessions = append(sessions, s) | 285 | sessions = append(sessions, s) | ||
| 270 | } | 286 | } | ||
| 271 | d.mu.Unlock() | 287 | d.mu.Unlock() | ||
| 272 | - | defer d.notices.routingMu.Unlock() | |||
| 273 | 288 | ||||
| 274 | if d.notices.beforeGlobalDelivery != nil { | 289 | if d.notices.beforeGlobalDelivery != nil { | ||
| 275 | d.notices.beforeGlobalDelivery() | 290 | d.notices.beforeGlobalDelivery() | ||
| 276 | } | 291 | } | ||
| 277 | - | delivered := false | 292 | + | targets := make([]*attachedClient, 0, len(sessions)) |
| 278 | for _, s := range sessions { | 293 | for _, s := range sessions { | ||
| 279 | s.mu.Lock() | 294 | s.mu.Lock() | ||
| 280 | ac := s.client | 295 | ac := s.client | ||
| 281 | s.mu.Unlock() | 296 | s.mu.Unlock() | ||
| 282 | - | if ac == nil { | 297 | + | if ac == nil || !d.publishToast(ac, n) { |
| 283 | continue | 298 | continue | ||
| 284 | } | 299 | } | ||
| 285 | - | d.showToast(ac, n) | 300 | + | targets = append(targets, ac) |
| 286 | - | delivered = true | |||
| 287 | } | 301 | } | ||
| 288 | - | if !delivered { | 302 | + | if len(targets) == 0 { |
| 289 | if d.notices.beforeQueueGlobal != nil { | 303 | if d.notices.beforeQueueGlobal != nil { | ||
| 290 | d.notices.beforeQueueGlobal() | 304 | d.notices.beforeQueueGlobal() | ||
| 291 | } | 305 | } | ||
| 292 | d.notices.queueGlobal(n) | 306 | d.notices.queueGlobal(n) | ||
| 293 | } | 307 | } | ||
| 308 | + | d.notices.routingMu.Unlock() | |||
| 309 | + | ||||
| 310 | + | for _, ac := range targets { | |||
| 311 | + | d.repaintForNotice(ac) | |||
| 312 | + | } | |||
| 294 | } | 313 | } | ||
| 295 | 314 | ||||
| 296 | - | // drainPendingForFirstPaint publishes pending globals only while ac remains the | 315 | + | // drainPendingForFirstPaint publishes pending globals and notices scoped to |
| 297 | - | // session's current attachment. It shares routingMu with deliverGlobal, so an | 316 | + | // sess only while ac remains the session's current attachment. It shares |
| 298 | - | // unattached global route cannot queue after this drain has already happened. | 317 | + | // routingMu with notice routing, so an unattached route cannot queue after this |
| 318 | + | // drain has already happened. | |||
| 299 | func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) { | 319 | func (d *Daemon) drainPendingForFirstPaint(sess *session, ac *attachedClient) { | ||
| 300 | d.notices.routingMu.Lock() | 320 | d.notices.routingMu.Lock() | ||
| 301 | - | defer d.notices.routingMu.Unlock() | |||
| 302 | 321 | ||||
| 303 | sess.mu.Lock() | 322 | sess.mu.Lock() | ||
| 304 | current := sess.client == ac | 323 | current := sess.client == ac | ||
| 305 | sess.mu.Unlock() | 324 | sess.mu.Unlock() | ||
| 306 | if !current { | 325 | if !current { | ||
| 326 | + | d.notices.routingMu.Unlock() | |||
| 307 | return | 327 | return | ||
| 308 | } | 328 | } | ||
| 329 | + | published := false | |||
| 330 | + | var keep []domain.Notification | |||
| 309 | for _, n := range d.notices.drainPending() { | 331 | for _, n := range d.notices.drainPending() { | ||
| 310 | - | d.showToast(ac, n) | 332 | + | if n.SessionID == "" || n.SessionID == sess.id { |
| 333 | + | published = d.publishToast(ac, n) || published | |||
| 334 | + | continue | |||
| 335 | + | } | |||
| 336 | + | keep = append(keep, n) | |||
| 337 | + | } | |||
| 338 | + | for _, n := range keep { | |||
| 339 | + | d.notices.queueGlobal(n) | |||
| 340 | + | } | |||
| 341 | + | d.notices.routingMu.Unlock() | |||
| 342 | + | if published { | |||
| 343 | + | d.repaintForNotice(ac) | |||
| 311 | } | 344 | } | ||
| 312 | } | 345 | } | ||
| 313 | 346 | ||||
| @@ -316,27 +349,32 @@ func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode, | |||||
| 316 | d.notify(nil, sev, code, msg, cause) | 349 | d.notify(nil, sev, code, msg, cause) | ||
| 317 | } | 350 | } | ||
| 318 | 351 | ||||
| 319 | - | // showToast publishes a notice into one client's toast stack. An identical | 352 | + | // showToast publishes a notice into one client's toast stack and repaints only |
| 320 | - | // code within the same scope coalesces into the existing entry and restarts its | 353 | + | // after the state mutation is complete. |
| 321 | - | // TTL; otherwise the notice is prepended and the stack trimmed. The repaint is | |||
| 322 | - | // issued after noticeMu is released. | |||
| 323 | func (d *Daemon) showToast(ac *attachedClient, n domain.Notification) { | 354 | func (d *Daemon) showToast(ac *attachedClient, n domain.Notification) { | ||
| 355 | + | if d.publishToast(ac, n) { | |||
| 356 | + | d.repaintForNotice(ac) | |||
| 357 | + | } | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | // publishToast durably mutates one client's toast state without repainting. | |||
| 361 | + | // Routing paths call it while routingMu excludes detach, then release routingMu | |||
| 362 | + | // before rendering so a render failure can safely route another notice. | |||
| 363 | + | func (d *Daemon) publishToast(ac *attachedClient, n domain.Notification) bool { | |||
| 324 | if ac == nil { | 364 | if ac == nil { | ||
| 325 | - | return | 365 | + | return false |
| 326 | } | 366 | } | ||
| 327 | ac.initOverlays() | 367 | ac.initOverlays() | ||
| 328 | rt := ac.overlays | 368 | rt := ac.overlays | ||
| 329 | 369 | ||||
| 330 | rt.noticeMu.Lock() | 370 | rt.noticeMu.Lock() | ||
| 331 | - | if i := rt.indexOfToastLocked(n.Code, n.SessionID); i >= 0 { | 371 | + | defer rt.noticeMu.Unlock() |
| 372 | + | if i := rt.indexOfMatchingToastLocked(n); i >= 0 { | |||
| 332 | rt.noticeToasts[i].n.Count += n.Count | 373 | rt.noticeToasts[i].n.Count += n.Count | ||
| 333 | rt.noticeToasts[i].n.Time = n.Time | 374 | rt.noticeToasts[i].n.Time = n.Time | ||
| 334 | - | rt.noticeToasts[i].n.Details = n.Details | |||
| 335 | rt.noticeToasts[i].timer.stop() | 375 | rt.noticeToasts[i].timer.stop() | ||
| 336 | d.retainToastTimerLocked(ac, &rt.noticeToasts[i]) | 376 | d.retainToastTimerLocked(ac, &rt.noticeToasts[i]) | ||
| 337 | - | rt.noticeMu.Unlock() | 377 | + | return true |
| 338 | - | d.repaintForNotice(ac) | |||
| 339 | - | return | |||
| 340 | } | 378 | } | ||
| 341 | 379 | ||||
| 342 | toast := noticeToast{n: n} | 380 | toast := noticeToast{n: n} | ||
| @@ -349,8 +387,34 @@ func (d *Daemon) showToast(ac *attachedClient, n domain.Notification) { | |||||
| 349 | rt.noticeToasts = rt.noticeToasts[:maxVisibleToasts] | 387 | rt.noticeToasts = rt.noticeToasts[:maxVisibleToasts] | ||
| 350 | } | 388 | } | ||
| 351 | d.retainToastTimerLocked(ac, &rt.noticeToasts[0]) | 389 | d.retainToastTimerLocked(ac, &rt.noticeToasts[0]) | ||
| 352 | - | rt.noticeMu.Unlock() | 390 | + | return true |
| 353 | - | d.repaintForNotice(ac) | 391 | + | } |
| 392 | + | ||||
| 393 | + | func sameToastNotice(a, b domain.Notification) bool { | |||
| 394 | + | if a.Code != b.Code || a.SessionID != b.SessionID { | |||
| 395 | + | return false | |||
| 396 | + | } | |||
| 397 | + | // NoticeUser is intentionally generic: its message and selected severity | |||
| 398 | + | // are the notice identity. Typed daemon notices retain their established | |||
| 399 | + | // code-and-scope coalescing behavior. | |||
| 400 | + | if a.Code != domain.NoticeUser { | |||
| 401 | + | return true | |||
| 402 | + | } | |||
| 403 | + | return a.Severity == b.Severity && | |||
| 404 | + | a.Message == b.Message && | |||
| 405 | + | a.Details == b.Details | |||
| 406 | + | } | |||
| 407 | + | ||||
| 408 | + | // indexOfMatchingToastLocked finds a visible toast with the code-specific | |||
| 409 | + | // coalescing identity. Count and time are occurrence metadata, not identity. | |||
| 410 | + | // Callers must hold noticeMu. | |||
| 411 | + | func (rt *overlayRuntime) indexOfMatchingToastLocked(n domain.Notification) int { | |||
| 412 | + | for i := range rt.noticeToasts { | |||
| 413 | + | if sameToastNotice(rt.noticeToasts[i].n, n) { | |||
| 414 | + | return i | |||
| 415 | + | } | |||
| 416 | + | } | |||
| 417 | + | return -1 | |||
| 354 | } | 418 | } | ||
| 355 | 419 | ||||
| 356 | // indexOfToastLocked finds a visible toast with the same code and scope. | 420 | // indexOfToastLocked finds a visible toast with the same code and scope. | ||
M internal/usecase/daemon/notify_test.go +166 -15
| @@ -254,10 +254,10 @@ func TestNoticeCenterPendingQueue(t *testing.T) { | |||
| 254 | 254 | for i := 0; i < 32; i++ { | |
| 255 | 255 | nc.queueGlobal(notice(domain.NoticeCode(i), fmt.Sprintf("code %d", i), "")) | |
| 256 | 256 | } | |
| 257 | - | // The queue is now full. Re-queuing an existing code must coalesce | |
| 257 | + | // The queue is now full. Re-queuing an identical notice must coalesce | |
| 258 | 258 | // in place, not hit the overflow-drop branch. | |
| 259 | - | nc.queueGlobal(notice(domain.NoticeCode(0), "code 0 again", "")) | |
| 260 | - | nc.queueGlobal(notice(domain.NoticeCode(0), "code 0 again", "")) | |
| 259 | + | nc.queueGlobal(notice(domain.NoticeCode(0), "code 0", "")) | |
| 260 | + | nc.queueGlobal(notice(domain.NoticeCode(0), "code 0", "")) | |
| 261 | 261 | pending := nc.drainPending() | |
| 262 | 262 | if len(pending) != 32 { | |
| 263 | 263 | t.Fatalf("pending len = %d, want 32 (dedup must not grow past cap)", len(pending)) | |
| @@ -525,18 +525,128 @@ func TestNotifyRoutesToSessionClientOnly(t *testing.T) { | |||
| 525 | 525 | require.Empty(t, otherToasts, "another session's client must be untouched by session-scoped notices") | |
| 526 | 526 | } | |
| 527 | 527 | ||
| 528 | - | func TestNotifySessionWithoutClientRecordsHistoryOnly(t *testing.T) { | |
| 528 | + | func TestNotifySessionScopedSerializesDetachThroughToastPublication(t *testing.T) { | |
| 529 | 529 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| 530 | - | sess.mu.Lock() | |
| 531 | - | sess.client = nil | |
| 532 | - | sess.mu.Unlock() | |
| 533 | 530 | ||
| 534 | - | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil) | |
| 531 | + | selected := make(chan bool, 1) | |
| 532 | + | releasePublication := make(chan struct{}) | |
| 533 | + | d.notices.beforeSessionDelivery = func() { | |
| 534 | + | unlocked := d.notices.routingMu.TryLock() | |
| 535 | + | if unlocked { | |
| 536 | + | d.notices.routingMu.Unlock() | |
| 537 | + | } | |
| 538 | + | selected <- !unlocked | |
| 539 | + | <-releasePublication | |
| 540 | + | } | |
| 541 | + | t.Cleanup(func() { | |
| 542 | + | select { | |
| 543 | + | case <-releasePublication: | |
| 544 | + | default: | |
| 545 | + | close(releasePublication) | |
| 546 | + | } | |
| 547 | + | }) | |
| 548 | + | ||
| 549 | + | notifyDone := make(chan struct{}) | |
| 550 | + | go func() { | |
| 551 | + | d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "hello", nil) | |
| 552 | + | close(notifyDone) | |
| 553 | + | }() | |
| 554 | + | require.True(t, <-selected, "routingMu must remain held from attachment selection through toast publication") | |
| 555 | + | ||
| 556 | + | detachStarted := make(chan struct{}) | |
| 557 | + | detachDone := make(chan bool, 1) | |
| 558 | + | go func() { | |
| 559 | + | close(detachStarted) | |
| 560 | + | detachDone <- d.detachIfCurrent(sess, ac) | |
| 561 | + | }() | |
| 562 | + | <-detachStarted | |
| 563 | + | select { | |
| 564 | + | case <-detachDone: | |
| 565 | + | t.Fatal("detach completed before the selected toast was durably published") | |
| 566 | + | default: | |
| 567 | + | } | |
| 568 | + | ||
| 569 | + | close(releasePublication) | |
| 570 | + | <-notifyDone | |
| 571 | + | require.True(t, <-detachDone) | |
| 535 | 572 | ||
| 536 | - | require.Len(t, d.notices.history(), 1) | |
| 537 | 573 | toasts, _ := visibleToasts(ac) | |
| 538 | - | require.Empty(t, toasts, "a detached session must not paint a toast") | |
| 539 | - | require.Empty(t, d.notices.drainPending(), "session-scoped notices are never queued as pending globals") | |
| 574 | + | require.Len(t, toasts, 1) | |
| 575 | + | require.Equal(t, "hello", toasts[0].Message) | |
| 576 | + | require.Empty(t, d.notices.drainPending(), "a toast published before detach must not also be queued") | |
| 577 | + | } | |
| 578 | + | ||
| 579 | + | func TestNotifySessionScopedReleasesRoutingBeforeRepaint(t *testing.T) { | |
| 580 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| 581 | + | ||
| 582 | + | repaintObserved := make(chan bool, 1) | |
| 583 | + | ac.renderStages.capture = func() { | |
| 584 | + | unlocked := d.notices.routingMu.TryLock() | |
| 585 | + | if unlocked { | |
| 586 | + | d.notices.routingMu.Unlock() | |
| 587 | + | } | |
| 588 | + | repaintObserved <- unlocked | |
| 589 | + | } | |
| 590 | + | ||
| 591 | + | d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "hello", nil) | |
| 592 | + | ||
| 593 | + | require.True(t, <-repaintObserved, "notice repaint must run without routingMu held") | |
| 594 | + | } | |
| 595 | + | ||
| 596 | + | func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) { | |
| 597 | + | d, sessA, oldA, _ := newNoticeFixture(t, newNoticeClock()) | |
| 598 | + | sessA.mu.Lock() | |
| 599 | + | sessA.client = nil | |
| 600 | + | sessA.mu.Unlock() | |
| 601 | + | ||
| 602 | + | clientB := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 603 | + | clientB.initOverlays() | |
| 604 | + | sessB := &session{id: "manual-2", name: "other", ctx: sessA.ctx, cancel: func() {}} | |
| 605 | + | clientB.setSession(sessB) | |
| 606 | + | d.mu.Lock() | |
| 607 | + | d.sessions[sessB.id] = sessB | |
| 608 | + | d.mu.Unlock() | |
| 609 | + | ||
| 610 | + | d.notify(sessA, domain.NoticeInfo, domain.NoticeUser, "hello from a script", nil) | |
| 611 | + | // Use the same code globally to prove pending coalescing includes scope. | |
| 612 | + | d.NotifyGlobal(domain.NoticeWarn, domain.NoticeUser, "global announcement", nil) | |
| 613 | + | ||
| 614 | + | history := d.notices.history() | |
| 615 | + | require.Len(t, history, 2) | |
| 616 | + | require.Equal(t, domain.NoticeUser, history[1].Code) | |
| 617 | + | oldToasts, _ := visibleToasts(oldA) | |
| 618 | + | require.Empty(t, oldToasts, "a detached session must not paint a toast") | |
| 619 | + | ||
| 620 | + | sessB.mu.Lock() | |
| 621 | + | sessB.client = clientB | |
| 622 | + | sessB.mu.Unlock() | |
| 623 | + | d.drainPendingForFirstPaint(sessB, clientB) | |
| 624 | + | bToasts, _ := visibleToasts(clientB) | |
| 625 | + | require.Len(t, bToasts, 1, "another session receives only the global notice") | |
| 626 | + | require.Equal(t, domain.NoticeUser, bToasts[0].Code) | |
| 627 | + | require.Empty(t, bToasts[0].SessionID) | |
| 628 | + | require.Equal(t, "global announcement", bToasts[0].Message) | |
| 629 | + | ||
| 630 | + | sessA.mu.Lock() | |
| 631 | + | sessA.client = oldA | |
| 632 | + | sessA.mu.Unlock() | |
| 633 | + | d.drainPendingForFirstPaint(sessA, oldA) | |
| 634 | + | aToasts, _ := visibleToasts(oldA) | |
| 635 | + | require.Len(t, aToasts, 1) | |
| 636 | + | require.Equal(t, domain.NoticeUser, aToasts[0].Code) | |
| 637 | + | require.Equal(t, sessA.id, aToasts[0].SessionID) | |
| 638 | + | require.Equal(t, "hello from a script", aToasts[0].Message) | |
| 639 | + | require.Equal(t, 1, aToasts[0].Count) | |
| 640 | + | ||
| 641 | + | secondA := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 642 | + | secondA.initOverlays() | |
| 643 | + | secondA.setSession(sessA) | |
| 644 | + | sessA.mu.Lock() | |
| 645 | + | sessA.client = secondA | |
| 646 | + | sessA.mu.Unlock() | |
| 647 | + | d.drainPendingForFirstPaint(sessA, secondA) | |
| 648 | + | secondToasts, _ := visibleToasts(secondA) | |
| 649 | + | require.Empty(t, secondToasts, "a drained session notice must not be delivered twice") | |
| 540 | 650 | } | |
| 541 | 651 | ||
| 542 | 652 | func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) { | |
| @@ -665,6 +775,30 @@ func TestNotifyGlobalSerializesReplacementWithDelivery(t *testing.T) { | |||
| 665 | 775 | sess.mu.Unlock() | |
| 666 | 776 | } | |
| 667 | 777 | ||
| 778 | + | func TestDetachedNoticeCoalescingPreservesDistinctContentAndSeverity(t *testing.T) { | |
| 779 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| 780 | + | sess.mu.Lock() | |
| 781 | + | sess.client = nil | |
| 782 | + | sess.mu.Unlock() | |
| 783 | + | ||
| 784 | + | d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build started", nil) | |
| 785 | + | d.notify(sess, domain.NoticeWarn, domain.NoticeUser, "build delayed", nil) | |
| 786 | + | d.notify(sess, domain.NoticeWarn, domain.NoticeUser, "build delayed", nil) | |
| 787 | + | ||
| 788 | + | sess.mu.Lock() | |
| 789 | + | sess.client = ac | |
| 790 | + | sess.mu.Unlock() | |
| 791 | + | d.drainPendingForFirstPaint(sess, ac) | |
| 792 | + | ||
| 793 | + | toasts := awaitToastCount(t, ac, 2) | |
| 794 | + | require.Equal(t, "build delayed", toasts[0].Message) | |
| 795 | + | require.Equal(t, domain.NoticeWarn, toasts[0].Severity) | |
| 796 | + | require.Equal(t, 2, toasts[0].Count, "identical detached notices coalesce") | |
| 797 | + | require.Equal(t, "build started", toasts[1].Message) | |
| 798 | + | require.Equal(t, domain.NoticeInfo, toasts[1].Severity) | |
| 799 | + | require.Equal(t, 1, toasts[1].Count) | |
| 800 | + | } | |
| 801 | + | ||
| 668 | 802 | func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) { | |
| 669 | 803 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| 670 | 804 | sess.mu.Lock() | |
| @@ -763,19 +897,36 @@ func TestNotifyLogsAtSeverityLevel(t *testing.T) { | |||
| 763 | 897 | } | |
| 764 | 898 | ||
| 765 | 899 | func TestShowToastCoalesceAndTrim(t *testing.T) { | |
| 766 | - | t.Run("same code and scope coalesces", func(t *testing.T) { | |
| 767 | - | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| 900 | + | t.Run("identical notices coalesce and renew their ttl", func(t *testing.T) { | |
| 901 | + | clk := newNoticeClock() | |
| 902 | + | d, sess, ac, _ := newNoticeFixture(t, clk) | |
| 768 | 903 | ||
| 769 | - | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil) | |
| 770 | - | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil) | |
| 904 | + | d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build finished", nil) | |
| 905 | + | d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build finished", nil) | |
| 771 | 906 | ||
| 772 | 907 | toasts := awaitToastCount(t, ac, 1) | |
| 773 | 908 | require.Equal(t, 2, toasts[0].Count) | |
| 909 | + | require.Equal(t, []time.Duration{4 * time.Second, 4 * time.Second}, clk.durations(), "coalescing renews the identical notice's ttl") | |
| 774 | 910 | _, overflow := visibleToasts(ac) | |
| 775 | 911 | require.Zero(t, overflow) | |
| 776 | 912 | require.Len(t, d.notices.history(), 2, "coalescing is display-only; history keeps both") | |
| 777 | 913 | }) | |
| 778 | 914 | ||
| 915 | + | t.Run("same code and scope preserve distinct message and severity", func(t *testing.T) { | |
| 916 | + | clk := newNoticeClock() | |
| 917 | + | d, sess, ac, _ := newNoticeFixture(t, clk) | |
| 918 | + | ||
| 919 | + | d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build started", nil) | |
| 920 | + | d.notify(sess, domain.NoticeWarn, domain.NoticeUser, "build delayed", nil) | |
| 921 | + | ||
| 922 | + | toasts := awaitToastCount(t, ac, 2) | |
| 923 | + | require.Equal(t, "build delayed", toasts[0].Message) | |
| 924 | + | require.Equal(t, domain.NoticeWarn, toasts[0].Severity) | |
| 925 | + | require.Equal(t, "build started", toasts[1].Message) | |
| 926 | + | require.Equal(t, domain.NoticeInfo, toasts[1].Severity) | |
| 927 | + | require.Equal(t, []time.Duration{4 * time.Second, 6 * time.Second}, clk.durations(), "each distinct notice keeps its severity ttl") | |
| 928 | + | }) | |
| 929 | + | ||
| 779 | 930 | t.Run("same code different scope does not coalesce", func(t *testing.T) { | |
| 780 | 931 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |
| 781 | 932 | ||
| @@ -254,10 +254,10 @@ func TestNoticeCenterPendingQueue(t *testing.T) { | |||||
| 254 | for i := 0; i < 32; i++ { | 254 | for i := 0; i < 32; i++ { | ||
| 255 | nc.queueGlobal(notice(domain.NoticeCode(i), fmt.Sprintf("code %d", i), "")) | 255 | nc.queueGlobal(notice(domain.NoticeCode(i), fmt.Sprintf("code %d", i), "")) | ||
| 256 | } | 256 | } | ||
| 257 | - | // The queue is now full. Re-queuing an existing code must coalesce | 257 | + | // The queue is now full. Re-queuing an identical notice must coalesce |
| 258 | // in place, not hit the overflow-drop branch. | 258 | // in place, not hit the overflow-drop branch. | ||
| 259 | - | nc.queueGlobal(notice(domain.NoticeCode(0), "code 0 again", "")) | 259 | + | nc.queueGlobal(notice(domain.NoticeCode(0), "code 0", "")) |
| 260 | - | nc.queueGlobal(notice(domain.NoticeCode(0), "code 0 again", "")) | 260 | + | nc.queueGlobal(notice(domain.NoticeCode(0), "code 0", "")) |
| 261 | pending := nc.drainPending() | 261 | pending := nc.drainPending() | ||
| 262 | if len(pending) != 32 { | 262 | if len(pending) != 32 { | ||
| 263 | t.Fatalf("pending len = %d, want 32 (dedup must not grow past cap)", len(pending)) | 263 | t.Fatalf("pending len = %d, want 32 (dedup must not grow past cap)", len(pending)) | ||
| @@ -525,18 +525,128 @@ func TestNotifyRoutesToSessionClientOnly(t *testing.T) { | |||||
| 525 | require.Empty(t, otherToasts, "another session's client must be untouched by session-scoped notices") | 525 | require.Empty(t, otherToasts, "another session's client must be untouched by session-scoped notices") | ||
| 526 | } | 526 | } | ||
| 527 | 527 | ||||
| 528 | - | func TestNotifySessionWithoutClientRecordsHistoryOnly(t *testing.T) { | 528 | + | func TestNotifySessionScopedSerializesDetachThroughToastPublication(t *testing.T) { |
| 529 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | 529 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | ||
| 530 | - | sess.mu.Lock() | |||
| 531 | - | sess.client = nil | |||
| 532 | - | sess.mu.Unlock() | |||
| 533 | 530 | ||||
| 534 | - | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil) | 531 | + | selected := make(chan bool, 1) |
| 532 | + | releasePublication := make(chan struct{}) | |||
| 533 | + | d.notices.beforeSessionDelivery = func() { | |||
| 534 | + | unlocked := d.notices.routingMu.TryLock() | |||
| 535 | + | if unlocked { | |||
| 536 | + | d.notices.routingMu.Unlock() | |||
| 537 | + | } | |||
| 538 | + | selected <- !unlocked | |||
| 539 | + | <-releasePublication | |||
| 540 | + | } | |||
| 541 | + | t.Cleanup(func() { | |||
| 542 | + | select { | |||
| 543 | + | case <-releasePublication: | |||
| 544 | + | default: | |||
| 545 | + | close(releasePublication) | |||
| 546 | + | } | |||
| 547 | + | }) | |||
| 548 | + | ||||
| 549 | + | notifyDone := make(chan struct{}) | |||
| 550 | + | go func() { | |||
| 551 | + | d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "hello", nil) | |||
| 552 | + | close(notifyDone) | |||
| 553 | + | }() | |||
| 554 | + | require.True(t, <-selected, "routingMu must remain held from attachment selection through toast publication") | |||
| 555 | + | ||||
| 556 | + | detachStarted := make(chan struct{}) | |||
| 557 | + | detachDone := make(chan bool, 1) | |||
| 558 | + | go func() { | |||
| 559 | + | close(detachStarted) | |||
| 560 | + | detachDone <- d.detachIfCurrent(sess, ac) | |||
| 561 | + | }() | |||
| 562 | + | <-detachStarted | |||
| 563 | + | select { | |||
| 564 | + | case <-detachDone: | |||
| 565 | + | t.Fatal("detach completed before the selected toast was durably published") | |||
| 566 | + | default: | |||
| 567 | + | } | |||
| 568 | + | ||||
| 569 | + | close(releasePublication) | |||
| 570 | + | <-notifyDone | |||
| 571 | + | require.True(t, <-detachDone) | |||
| 535 | 572 | ||||
| 536 | - | require.Len(t, d.notices.history(), 1) | |||
| 537 | toasts, _ := visibleToasts(ac) | 573 | toasts, _ := visibleToasts(ac) | ||
| 538 | - | require.Empty(t, toasts, "a detached session must not paint a toast") | 574 | + | require.Len(t, toasts, 1) |
| 539 | - | require.Empty(t, d.notices.drainPending(), "session-scoped notices are never queued as pending globals") | 575 | + | require.Equal(t, "hello", toasts[0].Message) |
| 576 | + | require.Empty(t, d.notices.drainPending(), "a toast published before detach must not also be queued") | |||
| 577 | + | } | |||
| 578 | + | ||||
| 579 | + | func TestNotifySessionScopedReleasesRoutingBeforeRepaint(t *testing.T) { | |||
| 580 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 581 | + | ||||
| 582 | + | repaintObserved := make(chan bool, 1) | |||
| 583 | + | ac.renderStages.capture = func() { | |||
| 584 | + | unlocked := d.notices.routingMu.TryLock() | |||
| 585 | + | if unlocked { | |||
| 586 | + | d.notices.routingMu.Unlock() | |||
| 587 | + | } | |||
| 588 | + | repaintObserved <- unlocked | |||
| 589 | + | } | |||
| 590 | + | ||||
| 591 | + | d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "hello", nil) | |||
| 592 | + | ||||
| 593 | + | require.True(t, <-repaintObserved, "notice repaint must run without routingMu held") | |||
| 594 | + | } | |||
| 595 | + | ||||
| 596 | + | func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) { | |||
| 597 | + | d, sessA, oldA, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 598 | + | sessA.mu.Lock() | |||
| 599 | + | sessA.client = nil | |||
| 600 | + | sessA.mu.Unlock() | |||
| 601 | + | ||||
| 602 | + | clientB := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |||
| 603 | + | clientB.initOverlays() | |||
| 604 | + | sessB := &session{id: "manual-2", name: "other", ctx: sessA.ctx, cancel: func() {}} | |||
| 605 | + | clientB.setSession(sessB) | |||
| 606 | + | d.mu.Lock() | |||
| 607 | + | d.sessions[sessB.id] = sessB | |||
| 608 | + | d.mu.Unlock() | |||
| 609 | + | ||||
| 610 | + | d.notify(sessA, domain.NoticeInfo, domain.NoticeUser, "hello from a script", nil) | |||
| 611 | + | // Use the same code globally to prove pending coalescing includes scope. | |||
| 612 | + | d.NotifyGlobal(domain.NoticeWarn, domain.NoticeUser, "global announcement", nil) | |||
| 613 | + | ||||
| 614 | + | history := d.notices.history() | |||
| 615 | + | require.Len(t, history, 2) | |||
| 616 | + | require.Equal(t, domain.NoticeUser, history[1].Code) | |||
| 617 | + | oldToasts, _ := visibleToasts(oldA) | |||
| 618 | + | require.Empty(t, oldToasts, "a detached session must not paint a toast") | |||
| 619 | + | ||||
| 620 | + | sessB.mu.Lock() | |||
| 621 | + | sessB.client = clientB | |||
| 622 | + | sessB.mu.Unlock() | |||
| 623 | + | d.drainPendingForFirstPaint(sessB, clientB) | |||
| 624 | + | bToasts, _ := visibleToasts(clientB) | |||
| 625 | + | require.Len(t, bToasts, 1, "another session receives only the global notice") | |||
| 626 | + | require.Equal(t, domain.NoticeUser, bToasts[0].Code) | |||
| 627 | + | require.Empty(t, bToasts[0].SessionID) | |||
| 628 | + | require.Equal(t, "global announcement", bToasts[0].Message) | |||
| 629 | + | ||||
| 630 | + | sessA.mu.Lock() | |||
| 631 | + | sessA.client = oldA | |||
| 632 | + | sessA.mu.Unlock() | |||
| 633 | + | d.drainPendingForFirstPaint(sessA, oldA) | |||
| 634 | + | aToasts, _ := visibleToasts(oldA) | |||
| 635 | + | require.Len(t, aToasts, 1) | |||
| 636 | + | require.Equal(t, domain.NoticeUser, aToasts[0].Code) | |||
| 637 | + | require.Equal(t, sessA.id, aToasts[0].SessionID) | |||
| 638 | + | require.Equal(t, "hello from a script", aToasts[0].Message) | |||
| 639 | + | require.Equal(t, 1, aToasts[0].Count) | |||
| 640 | + | ||||
| 641 | + | secondA := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |||
| 642 | + | secondA.initOverlays() | |||
| 643 | + | secondA.setSession(sessA) | |||
| 644 | + | sessA.mu.Lock() | |||
| 645 | + | sessA.client = secondA | |||
| 646 | + | sessA.mu.Unlock() | |||
| 647 | + | d.drainPendingForFirstPaint(sessA, secondA) | |||
| 648 | + | secondToasts, _ := visibleToasts(secondA) | |||
| 649 | + | require.Empty(t, secondToasts, "a drained session notice must not be delivered twice") | |||
| 540 | } | 650 | } | ||
| 541 | 651 | ||||
| 542 | func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) { | 652 | func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) { | ||
| @@ -665,6 +775,30 @@ func TestNotifyGlobalSerializesReplacementWithDelivery(t *testing.T) { | |||||
| 665 | sess.mu.Unlock() | 775 | sess.mu.Unlock() | ||
| 666 | } | 776 | } | ||
| 667 | 777 | ||||
| 778 | + | func TestDetachedNoticeCoalescingPreservesDistinctContentAndSeverity(t *testing.T) { | |||
| 779 | + | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | |||
| 780 | + | sess.mu.Lock() | |||
| 781 | + | sess.client = nil | |||
| 782 | + | sess.mu.Unlock() | |||
| 783 | + | ||||
| 784 | + | d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build started", nil) | |||
| 785 | + | d.notify(sess, domain.NoticeWarn, domain.NoticeUser, "build delayed", nil) | |||
| 786 | + | d.notify(sess, domain.NoticeWarn, domain.NoticeUser, "build delayed", nil) | |||
| 787 | + | ||||
| 788 | + | sess.mu.Lock() | |||
| 789 | + | sess.client = ac | |||
| 790 | + | sess.mu.Unlock() | |||
| 791 | + | d.drainPendingForFirstPaint(sess, ac) | |||
| 792 | + | ||||
| 793 | + | toasts := awaitToastCount(t, ac, 2) | |||
| 794 | + | require.Equal(t, "build delayed", toasts[0].Message) | |||
| 795 | + | require.Equal(t, domain.NoticeWarn, toasts[0].Severity) | |||
| 796 | + | require.Equal(t, 2, toasts[0].Count, "identical detached notices coalesce") | |||
| 797 | + | require.Equal(t, "build started", toasts[1].Message) | |||
| 798 | + | require.Equal(t, domain.NoticeInfo, toasts[1].Severity) | |||
| 799 | + | require.Equal(t, 1, toasts[1].Count) | |||
| 800 | + | } | |||
| 801 | + | ||||
| 668 | func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) { | 802 | func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) { | ||
| 669 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | 803 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | ||
| 670 | sess.mu.Lock() | 804 | sess.mu.Lock() | ||
| @@ -763,19 +897,36 @@ func TestNotifyLogsAtSeverityLevel(t *testing.T) { | |||||
| 763 | } | 897 | } | ||
| 764 | 898 | ||||
| 765 | func TestShowToastCoalesceAndTrim(t *testing.T) { | 899 | func TestShowToastCoalesceAndTrim(t *testing.T) { | ||
| 766 | - | t.Run("same code and scope coalesces", func(t *testing.T) { | 900 | + | t.Run("identical notices coalesce and renew their ttl", func(t *testing.T) { |
| 767 | - | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | 901 | + | clk := newNoticeClock() |
| 902 | + | d, sess, ac, _ := newNoticeFixture(t, clk) | |||
| 768 | 903 | ||||
| 769 | - | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil) | 904 | + | d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build finished", nil) |
| 770 | - | d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil) | 905 | + | d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build finished", nil) |
| 771 | 906 | ||||
| 772 | toasts := awaitToastCount(t, ac, 1) | 907 | toasts := awaitToastCount(t, ac, 1) | ||
| 773 | require.Equal(t, 2, toasts[0].Count) | 908 | require.Equal(t, 2, toasts[0].Count) | ||
| 909 | + | require.Equal(t, []time.Duration{4 * time.Second, 4 * time.Second}, clk.durations(), "coalescing renews the identical notice's ttl") | |||
| 774 | _, overflow := visibleToasts(ac) | 910 | _, overflow := visibleToasts(ac) | ||
| 775 | require.Zero(t, overflow) | 911 | require.Zero(t, overflow) | ||
| 776 | require.Len(t, d.notices.history(), 2, "coalescing is display-only; history keeps both") | 912 | require.Len(t, d.notices.history(), 2, "coalescing is display-only; history keeps both") | ||
| 777 | }) | 913 | }) | ||
| 778 | 914 | ||||
| 915 | + | t.Run("same code and scope preserve distinct message and severity", func(t *testing.T) { | |||
| 916 | + | clk := newNoticeClock() | |||
| 917 | + | d, sess, ac, _ := newNoticeFixture(t, clk) | |||
| 918 | + | ||||
| 919 | + | d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build started", nil) | |||
| 920 | + | d.notify(sess, domain.NoticeWarn, domain.NoticeUser, "build delayed", nil) | |||
| 921 | + | ||||
| 922 | + | toasts := awaitToastCount(t, ac, 2) | |||
| 923 | + | require.Equal(t, "build delayed", toasts[0].Message) | |||
| 924 | + | require.Equal(t, domain.NoticeWarn, toasts[0].Severity) | |||
| 925 | + | require.Equal(t, "build started", toasts[1].Message) | |||
| 926 | + | require.Equal(t, domain.NoticeInfo, toasts[1].Severity) | |||
| 927 | + | require.Equal(t, []time.Duration{4 * time.Second, 6 * time.Second}, clk.durations(), "each distinct notice keeps its severity ttl") | |||
| 928 | + | }) | |||
| 929 | + | ||||
| 779 | t.Run("same code different scope does not coalesce", func(t *testing.T) { | 930 | t.Run("same code different scope does not coalesce", func(t *testing.T) { | ||
| 780 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | 931 | d, sess, ac, _ := newNoticeFixture(t, newNoticeClock()) | ||
| 781 | 932 | ||||
M internal/usecase/daemon/palette.go +46 -53
| @@ -103,7 +103,7 @@ func recentSessionHints(recent []recentSession, args []string) palette.Contextua | |||
| 103 | 103 | } | |
| 104 | 104 | ||
| 105 | 105 | func (d *Daemon) paletteCommands() []command.Command { | |
| 106 | - | commands := command.Registry() | |
| 106 | + | commands := command.PaletteRegistry() | |
| 107 | 107 | d.paletteRecentMu.Lock() | |
| 108 | 108 | recent := append([]string(nil), d.paletteRecent...) | |
| 109 | 109 | d.paletteRecentMu.Unlock() | |
| @@ -303,7 +303,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 303 | 303 | if !ac.closeExecutedPalette(generation, rawQuery) { | |
| 304 | 304 | return | |
| 305 | 305 | } | |
| 306 | - | if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}, args); err != nil { | |
| 306 | + | sess.dispatchMu.Lock() | |
| 307 | + | err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}, args) | |
| 308 | + | sess.dispatchMu.Unlock() | |
| 309 | + | if err != nil { | |
| 307 | 310 | d.log.Error("palette command failed", "err", err, "command", cmd.Code) | |
| 308 | 311 | d.reportError(sess, err) | |
| 309 | 312 | } else { | |
| @@ -348,15 +351,30 @@ func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery strin | |||
| 348 | 351 | } | |
| 349 | 352 | ||
| 350 | 353 | type paletteExec struct { | |
| 351 | - | d *Daemon | |
| 352 | - | sess *session | |
| 353 | - | ac *attachedClient | |
| 354 | - | recent []recentSession | |
| 354 | + | d *Daemon | |
| 355 | + | sess *session | |
| 356 | + | ac *attachedClient | |
| 357 | + | recent []recentSession | |
| 358 | + | actions daemonActionRunner | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | func (e paletteExec) runAction(request daemonActionRequest) error { | |
| 362 | + | if request.target.session == nil { | |
| 363 | + | request.target = resolveDaemonActionTarget(e.sess) | |
| 364 | + | } | |
| 365 | + | runner := e.actions | |
| 366 | + | if runner == nil { | |
| 367 | + | runner = daemonActions{d: e.d} | |
| 368 | + | } | |
| 369 | + | err := runner.Run(request) | |
| 370 | + | if err == nil && e.actions == nil { | |
| 371 | + | finishDaemonActionForClient(e.d, request, e.ac, "palette.go") | |
| 372 | + | } | |
| 373 | + | return err | |
| 355 | 374 | } | |
| 356 | 375 | ||
| 357 | 376 | func (e paletteExec) CreateTab() error { | |
| 358 | - | defer e.d.invalidateRender(e.sess, e.ac, true, "palette.go") | |
| 359 | - | return e.d.createTab(e.sess, e.ac.size) | |
| 377 | + | return e.runAction(daemonActionRequest{kind: daemonActionCreateTab, viewport: e.ac.size}) | |
| 360 | 378 | } | |
| 361 | 379 | ||
| 362 | 380 | func (e paletteExec) CreateSession() error { | |
| @@ -367,70 +385,45 @@ func (e paletteExec) CreateSession() error { | |||
| 367 | 385 | } | |
| 368 | 386 | ||
| 369 | 387 | func (e paletteExec) CloseTab() error { | |
| 370 | - | if tb := e.sess.activeTab(); tb != nil { | |
| 371 | - | e.d.closeTab(e.sess, tb, true) | |
| 372 | - | } | |
| 373 | - | return nil | |
| 388 | + | return e.runAction(daemonActionRequest{kind: daemonActionCloseTab}) | |
| 374 | 389 | } | |
| 375 | 390 | ||
| 376 | - | func (e paletteExec) SplitRight() error { | |
| 377 | - | return e.d.splitPane(e.sess, e.ac, layout.Right) | |
| 391 | + | func (e paletteExec) split(direction layout.Direction) error { | |
| 392 | + | return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction}) | |
| 378 | 393 | } | |
| 379 | 394 | ||
| 380 | - | func (e paletteExec) SplitLeft() error { | |
| 381 | - | return e.d.splitPane(e.sess, e.ac, layout.Left) | |
| 382 | - | } | |
| 383 | - | ||
| 384 | - | func (e paletteExec) SplitUp() error { | |
| 385 | - | return e.d.splitPane(e.sess, e.ac, layout.Up) | |
| 386 | - | } | |
| 387 | - | ||
| 388 | - | func (e paletteExec) SplitDown() error { | |
| 389 | - | return e.d.splitPane(e.sess, e.ac, layout.Down) | |
| 390 | - | } | |
| 395 | + | func (e paletteExec) SplitRight() error { return e.split(layout.Right) } | |
| 396 | + | func (e paletteExec) SplitLeft() error { return e.split(layout.Left) } | |
| 397 | + | func (e paletteExec) SplitUp() error { return e.split(layout.Up) } | |
| 398 | + | func (e paletteExec) SplitDown() error { return e.split(layout.Down) } | |
| 391 | 399 | ||
| 392 | 400 | func (e paletteExec) StackPane() error { | |
| 393 | - | return e.d.stackPane(e.sess, e.ac) | |
| 401 | + | return e.runAction(daemonActionRequest{kind: daemonActionStackPane}) | |
| 394 | 402 | } | |
| 395 | 403 | ||
| 396 | 404 | func (e paletteExec) ToggleStack() error { | |
| 397 | - | return e.d.toggleStack(e.sess, e.ac) | |
| 405 | + | return e.runAction(daemonActionRequest{kind: daemonActionToggleStack}) | |
| 398 | 406 | } | |
| 399 | 407 | ||
| 400 | 408 | func (e paletteExec) ClosePane() error { | |
| 401 | - | return e.d.closeFocusedPane(e.sess, e.ac) | |
| 402 | - | } | |
| 403 | - | ||
| 404 | - | func (e paletteExec) FocusPaneLeft() error { | |
| 405 | - | return e.d.focusDir(e.sess, e.ac, layout.Left) | |
| 406 | - | } | |
| 407 | - | ||
| 408 | - | func (e paletteExec) FocusPaneRight() error { | |
| 409 | - | return e.d.focusDir(e.sess, e.ac, layout.Right) | |
| 409 | + | return e.runAction(daemonActionRequest{kind: daemonActionClosePane}) | |
| 410 | 410 | } | |
| 411 | 411 | ||
| 412 | - | func (e paletteExec) FocusPaneUp() error { | |
| 413 | - | return e.d.focusDir(e.sess, e.ac, layout.Up) | |
| 412 | + | func (e paletteExec) focus(direction layout.Direction) error { | |
| 413 | + | return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction}) | |
| 414 | 414 | } | |
| 415 | 415 | ||
| 416 | - | func (e paletteExec) FocusPaneDown() error { | |
| 417 | - | return e.d.focusDir(e.sess, e.ac, layout.Down) | |
| 418 | - | } | |
| 416 | + | func (e paletteExec) FocusPaneLeft() error { return e.focus(layout.Left) } | |
| 417 | + | func (e paletteExec) FocusPaneRight() error { return e.focus(layout.Right) } | |
| 418 | + | func (e paletteExec) FocusPaneUp() error { return e.focus(layout.Up) } | |
| 419 | + | func (e paletteExec) FocusPaneDown() error { return e.focus(layout.Down) } | |
| 419 | 420 | ||
| 420 | 421 | func (e paletteExec) NextTab() error { | |
| 421 | - | if e.sess.switchRelative(1) { | |
| 422 | - | e.d.activateTab(e.sess, e.sess.activeTab()) | |
| 423 | - | } | |
| 424 | - | e.d.invalidateRender(e.sess, e.ac, true, "palette.go") | |
| 425 | - | return nil | |
| 422 | + | return e.runAction(daemonActionRequest{kind: daemonActionNextTab}) | |
| 426 | 423 | } | |
| 427 | 424 | ||
| 428 | 425 | func (e paletteExec) PrevTab() error { | |
| 429 | - | if e.sess.switchRelative(-1) { | |
| 430 | - | e.d.activateTab(e.sess, e.sess.activeTab()) | |
| 431 | - | } | |
| 432 | - | e.d.invalidateRender(e.sess, e.ac, true, "palette.go") | |
| 433 | - | return nil | |
| 426 | + | return e.runAction(daemonActionRequest{kind: daemonActionPreviousTab}) | |
| 434 | 427 | } | |
| 435 | 428 | ||
| 436 | 429 | func (e paletteExec) ToggleFloatingPane() error { | |
| @@ -457,7 +450,7 @@ func (e paletteExec) RenameSession() error { | |||
| 457 | 450 | currentName := e.sess.name | |
| 458 | 451 | e.sess.mu.Unlock() | |
| 459 | 452 | e.d.enterPrompt(e.sess, e.ac, " Rename session ", currentName, func(name string) error { | |
| 460 | - | return e.d.renameSession(e.sess, name) | |
| 453 | + | return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name}) | |
| 461 | 454 | }) | |
| 462 | 455 | return nil | |
| 463 | 456 | } | |
| @@ -471,7 +464,7 @@ func (e paletteExec) RenameTab() error { | |||
| 471 | 464 | currentName := tabDisplayName(tb, e.sess.active) | |
| 472 | 465 | e.sess.mu.Unlock() | |
| 473 | 466 | e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error { | |
| 474 | - | return e.d.renameTab(e.sess, tb, name) | |
| 467 | + | return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, target: daemonActionTarget{session: e.sess, tab: tb}, name: name}) | |
| 475 | 468 | }) | |
| 476 | 469 | return nil | |
| 477 | 470 | } | |
| @@ -103,7 +103,7 @@ func recentSessionHints(recent []recentSession, args []string) palette.Contextua | |||||
| 103 | } | 103 | } | ||
| 104 | 104 | ||||
| 105 | func (d *Daemon) paletteCommands() []command.Command { | 105 | func (d *Daemon) paletteCommands() []command.Command { | ||
| 106 | - | commands := command.Registry() | 106 | + | commands := command.PaletteRegistry() |
| 107 | d.paletteRecentMu.Lock() | 107 | d.paletteRecentMu.Lock() | ||
| 108 | recent := append([]string(nil), d.paletteRecent...) | 108 | recent := append([]string(nil), d.paletteRecent...) | ||
| 109 | d.paletteRecentMu.Unlock() | 109 | d.paletteRecentMu.Unlock() | ||
| @@ -303,7 +303,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||||
| 303 | if !ac.closeExecutedPalette(generation, rawQuery) { | 303 | if !ac.closeExecutedPalette(generation, rawQuery) { | ||
| 304 | return | 304 | return | ||
| 305 | } | 305 | } | ||
| 306 | - | if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}, args); err != nil { | 306 | + | sess.dispatchMu.Lock() |
| 307 | + | err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}, args) | |||
| 308 | + | sess.dispatchMu.Unlock() | |||
| 309 | + | if err != nil { | |||
| 307 | d.log.Error("palette command failed", "err", err, "command", cmd.Code) | 310 | d.log.Error("palette command failed", "err", err, "command", cmd.Code) | ||
| 308 | d.reportError(sess, err) | 311 | d.reportError(sess, err) | ||
| 309 | } else { | 312 | } else { | ||
| @@ -348,15 +351,30 @@ func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery strin | |||||
| 348 | } | 351 | } | ||
| 349 | 352 | ||||
| 350 | type paletteExec struct { | 353 | type paletteExec struct { | ||
| 351 | - | d *Daemon | 354 | + | d *Daemon |
| 352 | - | sess *session | 355 | + | sess *session |
| 353 | - | ac *attachedClient | 356 | + | ac *attachedClient |
| 354 | - | recent []recentSession | 357 | + | recent []recentSession |
| 358 | + | actions daemonActionRunner | |||
| 359 | + | } | |||
| 360 | + | ||||
| 361 | + | func (e paletteExec) runAction(request daemonActionRequest) error { | |||
| 362 | + | if request.target.session == nil { | |||
| 363 | + | request.target = resolveDaemonActionTarget(e.sess) | |||
| 364 | + | } | |||
| 365 | + | runner := e.actions | |||
| 366 | + | if runner == nil { | |||
| 367 | + | runner = daemonActions{d: e.d} | |||
| 368 | + | } | |||
| 369 | + | err := runner.Run(request) | |||
| 370 | + | if err == nil && e.actions == nil { | |||
| 371 | + | finishDaemonActionForClient(e.d, request, e.ac, "palette.go") | |||
| 372 | + | } | |||
| 373 | + | return err | |||
| 355 | } | 374 | } | ||
| 356 | 375 | ||||
| 357 | func (e paletteExec) CreateTab() error { | 376 | func (e paletteExec) CreateTab() error { | ||
| 358 | - | defer e.d.invalidateRender(e.sess, e.ac, true, "palette.go") | 377 | + | return e.runAction(daemonActionRequest{kind: daemonActionCreateTab, viewport: e.ac.size}) |
| 359 | - | return e.d.createTab(e.sess, e.ac.size) | |||
| 360 | } | 378 | } | ||
| 361 | 379 | ||||
| 362 | func (e paletteExec) CreateSession() error { | 380 | func (e paletteExec) CreateSession() error { | ||
| @@ -367,70 +385,45 @@ func (e paletteExec) CreateSession() error { | |||||
| 367 | } | 385 | } | ||
| 368 | 386 | ||||
| 369 | func (e paletteExec) CloseTab() error { | 387 | func (e paletteExec) CloseTab() error { | ||
| 370 | - | if tb := e.sess.activeTab(); tb != nil { | 388 | + | return e.runAction(daemonActionRequest{kind: daemonActionCloseTab}) |
| 371 | - | e.d.closeTab(e.sess, tb, true) | |||
| 372 | - | } | |||
| 373 | - | return nil | |||
| 374 | } | 389 | } | ||
| 375 | 390 | ||||
| 376 | - | func (e paletteExec) SplitRight() error { | 391 | + | func (e paletteExec) split(direction layout.Direction) error { |
| 377 | - | return e.d.splitPane(e.sess, e.ac, layout.Right) | 392 | + | return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction}) |
| 378 | } | 393 | } | ||
| 379 | 394 | ||||
| 380 | - | func (e paletteExec) SplitLeft() error { | 395 | + | func (e paletteExec) SplitRight() error { return e.split(layout.Right) } |
| 381 | - | return e.d.splitPane(e.sess, e.ac, layout.Left) | 396 | + | func (e paletteExec) SplitLeft() error { return e.split(layout.Left) } |
| 382 | - | } | 397 | + | func (e paletteExec) SplitUp() error { return e.split(layout.Up) } |
| 383 | - | 398 | + | func (e paletteExec) SplitDown() error { return e.split(layout.Down) } | |
| 384 | - | func (e paletteExec) SplitUp() error { | |||
| 385 | - | return e.d.splitPane(e.sess, e.ac, layout.Up) | |||
| 386 | - | } | |||
| 387 | - | ||||
| 388 | - | func (e paletteExec) SplitDown() error { | |||
| 389 | - | return e.d.splitPane(e.sess, e.ac, layout.Down) | |||
| 390 | - | } | |||
| 391 | 399 | ||||
| 392 | func (e paletteExec) StackPane() error { | 400 | func (e paletteExec) StackPane() error { | ||
| 393 | - | return e.d.stackPane(e.sess, e.ac) | 401 | + | return e.runAction(daemonActionRequest{kind: daemonActionStackPane}) |
| 394 | } | 402 | } | ||
| 395 | 403 | ||||
| 396 | func (e paletteExec) ToggleStack() error { | 404 | func (e paletteExec) ToggleStack() error { | ||
| 397 | - | return e.d.toggleStack(e.sess, e.ac) | 405 | + | return e.runAction(daemonActionRequest{kind: daemonActionToggleStack}) |
| 398 | } | 406 | } | ||
| 399 | 407 | ||||
| 400 | func (e paletteExec) ClosePane() error { | 408 | func (e paletteExec) ClosePane() error { | ||
| 401 | - | return e.d.closeFocusedPane(e.sess, e.ac) | 409 | + | return e.runAction(daemonActionRequest{kind: daemonActionClosePane}) |
| 402 | - | } | |||
| 403 | - | ||||
| 404 | - | func (e paletteExec) FocusPaneLeft() error { | |||
| 405 | - | return e.d.focusDir(e.sess, e.ac, layout.Left) | |||
| 406 | - | } | |||
| 407 | - | ||||
| 408 | - | func (e paletteExec) FocusPaneRight() error { | |||
| 409 | - | return e.d.focusDir(e.sess, e.ac, layout.Right) | |||
| 410 | } | 410 | } | ||
| 411 | 411 | ||||
| 412 | - | func (e paletteExec) FocusPaneUp() error { | 412 | + | func (e paletteExec) focus(direction layout.Direction) error { |
| 413 | - | return e.d.focusDir(e.sess, e.ac, layout.Up) | 413 | + | return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction}) |
| 414 | } | 414 | } | ||
| 415 | 415 | ||||
| 416 | - | func (e paletteExec) FocusPaneDown() error { | 416 | + | func (e paletteExec) FocusPaneLeft() error { return e.focus(layout.Left) } |
| 417 | - | return e.d.focusDir(e.sess, e.ac, layout.Down) | 417 | + | func (e paletteExec) FocusPaneRight() error { return e.focus(layout.Right) } |
| 418 | - | } | 418 | + | func (e paletteExec) FocusPaneUp() error { return e.focus(layout.Up) } |
| 419 | + | func (e paletteExec) FocusPaneDown() error { return e.focus(layout.Down) } | |||
| 419 | 420 | ||||
| 420 | func (e paletteExec) NextTab() error { | 421 | func (e paletteExec) NextTab() error { | ||
| 421 | - | if e.sess.switchRelative(1) { | 422 | + | return e.runAction(daemonActionRequest{kind: daemonActionNextTab}) |
| 422 | - | e.d.activateTab(e.sess, e.sess.activeTab()) | |||
| 423 | - | } | |||
| 424 | - | e.d.invalidateRender(e.sess, e.ac, true, "palette.go") | |||
| 425 | - | return nil | |||
| 426 | } | 423 | } | ||
| 427 | 424 | ||||
| 428 | func (e paletteExec) PrevTab() error { | 425 | func (e paletteExec) PrevTab() error { | ||
| 429 | - | if e.sess.switchRelative(-1) { | 426 | + | return e.runAction(daemonActionRequest{kind: daemonActionPreviousTab}) |
| 430 | - | e.d.activateTab(e.sess, e.sess.activeTab()) | |||
| 431 | - | } | |||
| 432 | - | e.d.invalidateRender(e.sess, e.ac, true, "palette.go") | |||
| 433 | - | return nil | |||
| 434 | } | 427 | } | ||
| 435 | 428 | ||||
| 436 | func (e paletteExec) ToggleFloatingPane() error { | 429 | func (e paletteExec) ToggleFloatingPane() error { | ||
| @@ -457,7 +450,7 @@ func (e paletteExec) RenameSession() error { | |||||
| 457 | currentName := e.sess.name | 450 | currentName := e.sess.name | ||
| 458 | e.sess.mu.Unlock() | 451 | e.sess.mu.Unlock() | ||
| 459 | e.d.enterPrompt(e.sess, e.ac, " Rename session ", currentName, func(name string) error { | 452 | e.d.enterPrompt(e.sess, e.ac, " Rename session ", currentName, func(name string) error { | ||
| 460 | - | return e.d.renameSession(e.sess, name) | 453 | + | return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name}) |
| 461 | }) | 454 | }) | ||
| 462 | return nil | 455 | return nil | ||
| 463 | } | 456 | } | ||
| @@ -471,7 +464,7 @@ func (e paletteExec) RenameTab() error { | |||||
| 471 | currentName := tabDisplayName(tb, e.sess.active) | 464 | currentName := tabDisplayName(tb, e.sess.active) | ||
| 472 | e.sess.mu.Unlock() | 465 | e.sess.mu.Unlock() | ||
| 473 | e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error { | 466 | e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error { | ||
| 474 | - | return e.d.renameTab(e.sess, tb, name) | 467 | + | return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, target: daemonActionTarget{session: e.sess, tab: tb}, name: name}) |
| 475 | }) | 468 | }) | ||
| 476 | return nil | 469 | return nil | ||
| 477 | } | 470 | } | ||
M internal/usecase/daemon/palette_test.go +31 -1
| @@ -574,6 +574,36 @@ func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) { | |||
| 574 | 574 | require.Equal(t, "NXT", cmd.Code) | |
| 575 | 575 | } | |
| 576 | 576 | ||
| 577 | + | func TestPaletteCodeOverridesRejectAPIOnlyCommands(t *testing.T) { | |
| 578 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 579 | + | ||
| 580 | + | overrides, warnings := d.buildCodeOverrides(map[string]string{ | |
| 581 | + | "new-tab": "TAB", | |
| 582 | + | "toast": "API", | |
| 583 | + | }) | |
| 584 | + | ||
| 585 | + | require.Equal(t, map[string]string{"new-tab": "TAB"}, overrides) | |
| 586 | + | require.Len(t, warnings, 1) | |
| 587 | + | require.Contains(t, warnings[0].Msg, `unknown command code slug "toast"`) | |
| 588 | + | } | |
| 589 | + | ||
| 590 | + | func TestCommandByEffectiveCodeExcludesAPIOnlyCommands(t *testing.T) { | |
| 591 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 592 | + | overrides := map[string]string{ | |
| 593 | + | "new-tab": "TAB", | |
| 594 | + | "toast": "API", | |
| 595 | + | } | |
| 596 | + | d.codeOverrides.Store(&overrides) | |
| 597 | + | ||
| 598 | + | cmd, ok := d.commandByEffectiveCode("tab") | |
| 599 | + | require.True(t, ok) | |
| 600 | + | require.Equal(t, "new-tab", cmd.Slug) | |
| 601 | + | require.Equal(t, "TAB", cmd.Code) | |
| 602 | + | ||
| 603 | + | _, ok = d.commandByEffectiveCode("API") | |
| 604 | + | require.False(t, ok) | |
| 605 | + | } | |
| 606 | + | ||
| 577 | 607 | func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) { | |
| 578 | 608 | d := &Daemon{} | |
| 579 | 609 | d.recordPaletteUse("SSP") | |
| @@ -608,7 +638,7 @@ func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | |||
| 608 | 638 | wg.Wait() | |
| 609 | 639 | ||
| 610 | 640 | commands := d.paletteCommands() | |
| 611 | - | require.Len(t, commands, len(command.Registry())) | |
| 641 | + | require.Len(t, commands, len(command.PaletteRegistry())) | |
| 612 | 642 | seen := map[string]bool{} | |
| 613 | 643 | for _, cmd := range commands { | |
| 614 | 644 | require.False(t, seen[cmd.Code], "duplicate command %s", cmd.Code) | |
| @@ -574,6 +574,36 @@ func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) { | |||||
| 574 | require.Equal(t, "NXT", cmd.Code) | 574 | require.Equal(t, "NXT", cmd.Code) | ||
| 575 | } | 575 | } | ||
| 576 | 576 | ||||
| 577 | + | func TestPaletteCodeOverridesRejectAPIOnlyCommands(t *testing.T) { | |||
| 578 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 579 | + | ||||
| 580 | + | overrides, warnings := d.buildCodeOverrides(map[string]string{ | |||
| 581 | + | "new-tab": "TAB", | |||
| 582 | + | "toast": "API", | |||
| 583 | + | }) | |||
| 584 | + | ||||
| 585 | + | require.Equal(t, map[string]string{"new-tab": "TAB"}, overrides) | |||
| 586 | + | require.Len(t, warnings, 1) | |||
| 587 | + | require.Contains(t, warnings[0].Msg, `unknown command code slug "toast"`) | |||
| 588 | + | } | |||
| 589 | + | ||||
| 590 | + | func TestCommandByEffectiveCodeExcludesAPIOnlyCommands(t *testing.T) { | |||
| 591 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 592 | + | overrides := map[string]string{ | |||
| 593 | + | "new-tab": "TAB", | |||
| 594 | + | "toast": "API", | |||
| 595 | + | } | |||
| 596 | + | d.codeOverrides.Store(&overrides) | |||
| 597 | + | ||||
| 598 | + | cmd, ok := d.commandByEffectiveCode("tab") | |||
| 599 | + | require.True(t, ok) | |||
| 600 | + | require.Equal(t, "new-tab", cmd.Slug) | |||
| 601 | + | require.Equal(t, "TAB", cmd.Code) | |||
| 602 | + | ||||
| 603 | + | _, ok = d.commandByEffectiveCode("API") | |||
| 604 | + | require.False(t, ok) | |||
| 605 | + | } | |||
| 606 | + | ||||
| 577 | func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) { | 607 | func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) { | ||
| 578 | d := &Daemon{} | 608 | d := &Daemon{} | ||
| 579 | d.recordPaletteUse("SSP") | 609 | d.recordPaletteUse("SSP") | ||
| @@ -608,7 +638,7 @@ func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | |||||
| 608 | wg.Wait() | 638 | wg.Wait() | ||
| 609 | 639 | ||||
| 610 | commands := d.paletteCommands() | 640 | commands := d.paletteCommands() | ||
| 611 | - | require.Len(t, commands, len(command.Registry())) | 641 | + | require.Len(t, commands, len(command.PaletteRegistry())) |
| 612 | seen := map[string]bool{} | 642 | seen := map[string]bool{} | ||
| 613 | for _, cmd := range commands { | 643 | for _, cmd := range commands { | ||
| 614 | require.False(t, seen[cmd.Code], "duplicate command %s", cmd.Code) | 644 | require.False(t, seen[cmd.Code], "duplicate command %s", cmd.Code) | ||
M internal/usecase/daemon/pane_actions.go +74 -44
| @@ -10,25 +10,31 @@ import ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Direction) error { | |
| 13 | + | target := resolveDaemonActionTarget(sess) | |
| 14 | + | err := d.splitPaneAt(sess, target.tab, target.pane, dir) | |
| 15 | + | if err == nil && ac != nil { | |
| 16 | + | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| 17 | + | } | |
| 18 | + | return err | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func (d *Daemon) splitPaneAt(sess *session, tb *tab, target *pane, dir layout.Direction) error { | |
| 13 | 22 | after := dir == layout.Right || dir == layout.Down | |
| 14 | - | return d.spawnPaneOp(sess, ac, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error { | |
| 23 | + | return d.spawnPaneOpAt(sess, tb, target, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error { | |
| 15 | 24 | return tree.Split(oldFocus, dir, after, newID, area) | |
| 16 | 25 | }) | |
| 17 | 26 | } | |
| 18 | 27 | ||
| 19 | - | func (d *Daemon) spawnPaneOp( | |
| 28 | + | func (d *Daemon) spawnPaneOpAt( | |
| 20 | 29 | sess *session, | |
| 21 | - | ac *attachedClient, | |
| 30 | + | tb *tab, | |
| 31 | + | target *pane, | |
| 22 | 32 | mutate func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error, | |
| 23 | 33 | ) error { | |
| 24 | 34 | if d.ptys == nil { | |
| 25 | - | if ac != nil { | |
| 26 | - | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| 27 | - | } | |
| 28 | 35 | return nil | |
| 29 | 36 | } | |
| 30 | - | tb := sess.activeTab() | |
| 31 | - | if tb == nil { | |
| 37 | + | if tb == nil || target == nil { | |
| 32 | 38 | return layout.ErrNotFound | |
| 33 | 39 | } | |
| 34 | 40 | ||
| @@ -43,7 +49,11 @@ func (d *Daemon) spawnPaneOp( | |||
| 43 | 49 | tb.mu.Unlock() | |
| 44 | 50 | return layout.ErrNotFound | |
| 45 | 51 | } | |
| 46 | - | oldFocus := tb.tree.Focus | |
| 52 | + | oldFocus := target.id | |
| 53 | + | if tb.panes[oldFocus] != target || !layout.ContainsLeaf(tb.tree.Root, oldFocus) { | |
| 54 | + | tb.mu.Unlock() | |
| 55 | + | return layout.ErrNotFound | |
| 56 | + | } | |
| 47 | 57 | newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID)) | |
| 48 | 58 | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | |
| 49 | 59 | if err := mutate(tb.tree, oldFocus, newID, area); err != nil { | |
| @@ -103,9 +113,6 @@ func (d *Daemon) spawnPaneOp( | |||
| 103 | 113 | ||
| 104 | 114 | d.startPaneGoroutines(sess, tb, p) | |
| 105 | 115 | markSnapshotDirty(sess) | |
| 106 | - | if ac != nil { | |
| 107 | - | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| 108 | - | } | |
| 109 | 116 | return nil | |
| 110 | 117 | } | |
| 111 | 118 | ||
| @@ -164,20 +171,34 @@ func placementContent(placements []layout.Placement, id layout.PaneID) domain.Re | |||
| 164 | 171 | func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} } | |
| 165 | 172 | ||
| 166 | 173 | func (d *Daemon) stackPane(sess *session, ac *attachedClient) error { | |
| 167 | - | return d.spawnPaneOp(sess, ac, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error { | |
| 174 | + | target := resolveDaemonActionTarget(sess) | |
| 175 | + | err := d.stackPaneAt(sess, target.tab, target.pane) | |
| 176 | + | if err == nil && ac != nil { | |
| 177 | + | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| 178 | + | } | |
| 179 | + | return err | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func (d *Daemon) stackPaneAt(sess *session, tb *tab, target *pane) error { | |
| 183 | + | return d.spawnPaneOpAt(sess, tb, target, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error { | |
| 168 | 184 | return tree.StackNew(oldFocus, newID, area) | |
| 169 | 185 | }) | |
| 170 | 186 | } | |
| 171 | 187 | ||
| 172 | 188 | func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error { | |
| 189 | + | target := resolveDaemonActionTarget(sess) | |
| 190 | + | err := d.toggleStackAt(sess, target.tab, target.pane) | |
| 191 | + | if err == nil && ac != nil { | |
| 192 | + | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| 193 | + | } | |
| 194 | + | return err | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func (d *Daemon) toggleStackAt(sess *session, tb *tab, target *pane) error { | |
| 173 | 198 | if d.ptys == nil { | |
| 174 | - | if ac != nil { | |
| 175 | - | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| 176 | - | } | |
| 177 | 199 | return nil | |
| 178 | 200 | } | |
| 179 | - | tb := sess.activeTab() | |
| 180 | - | if tb == nil { | |
| 201 | + | if tb == nil || target == nil { | |
| 181 | 202 | return layout.ErrNotFound | |
| 182 | 203 | } | |
| 183 | 204 | tb.mu.Lock() | |
| @@ -185,16 +206,17 @@ func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error { | |||
| 185 | 206 | tb.mu.Unlock() | |
| 186 | 207 | return layout.ErrNotFound | |
| 187 | 208 | } | |
| 188 | - | err := tb.tree.ToggleStack(tb.tree.Focus, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |
| 209 | + | if tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) { | |
| 210 | + | tb.mu.Unlock() | |
| 211 | + | return layout.ErrNotFound | |
| 212 | + | } | |
| 213 | + | err := tb.tree.ToggleStack(target.id, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |
| 189 | 214 | if err == nil { | |
| 190 | 215 | d.applyLayoutLocked(tb) | |
| 191 | 216 | } | |
| 192 | 217 | tb.mu.Unlock() | |
| 193 | 218 | if err == nil { | |
| 194 | 219 | markSnapshotDirty(sess) | |
| 195 | - | if ac != nil { | |
| 196 | - | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| 197 | - | } | |
| 198 | 220 | } | |
| 199 | 221 | return err | |
| 200 | 222 | } | |
| @@ -247,8 +269,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||
| 247 | 269 | if ac != nil { | |
| 248 | 270 | ac.overlays.clearCopyModeForPane(p) | |
| 249 | 271 | } | |
| 250 | - | d.closeTab(sess, tb, repaint) | |
| 251 | - | return nil | |
| 272 | + | return d.closeTab(sess, tb, repaint) | |
| 252 | 273 | } | |
| 253 | 274 | if err := tb.tree.Close(id); err != nil { | |
| 254 | 275 | tb.mu.Unlock() | |
| @@ -283,38 +304,47 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||
| 283 | 304 | } | |
| 284 | 305 | ||
| 285 | 306 | func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction) error { | |
| 286 | - | tb := sess.activeTab() | |
| 287 | - | if tb == nil { | |
| 307 | + | target := resolveDaemonActionTarget(sess) | |
| 308 | + | oldFocus := layout.PaneID("") | |
| 309 | + | if target.pane != nil { | |
| 310 | + | oldFocus = target.pane.id | |
| 311 | + | } | |
| 312 | + | err := d.focusDirAt(sess, target.tab, target.pane, dir) | |
| 313 | + | if err == nil && ac != nil { | |
| 314 | + | target.tab.mu.Lock() | |
| 315 | + | newFocus := target.tab.tree.Focus | |
| 316 | + | pl, hasPlacement := focusedPlacementLocked(target.tab) | |
| 317 | + | target.tab.mu.Unlock() | |
| 318 | + | if newFocus != oldFocus { | |
| 319 | + | d.exitCopyMode(ac) | |
| 320 | + | if hasPlacement && pl.TitleBar.Height > 0 { | |
| 321 | + | d.refreshPaneTitleOnFocus(sess, newFocus) | |
| 322 | + | } | |
| 323 | + | } | |
| 324 | + | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| 325 | + | } | |
| 326 | + | return err | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) error { | |
| 330 | + | if tb == nil || target == nil { | |
| 288 | 331 | return layout.ErrNotFound | |
| 289 | 332 | } | |
| 290 | 333 | tb.mu.Lock() | |
| 291 | - | if tb.tree == nil { | |
| 334 | + | if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) { | |
| 292 | 335 | tb.mu.Unlock() | |
| 293 | 336 | return layout.ErrNotFound | |
| 294 | 337 | } | |
| 295 | - | oldFocus := tb.tree.Focus | |
| 338 | + | oldFocus := target.id | |
| 339 | + | tb.tree.Focus = oldFocus | |
| 296 | 340 | err := tb.tree.FocusDir(dir, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |
| 297 | 341 | newFocus := tb.tree.Focus | |
| 298 | 342 | if err == nil { | |
| 299 | 343 | d.applyLayoutLocked(tb) | |
| 300 | 344 | } | |
| 301 | 345 | tb.mu.Unlock() | |
| 302 | - | if err == nil { | |
| 303 | - | if newFocus != oldFocus { | |
| 304 | - | markSnapshotDirty(sess) | |
| 305 | - | } | |
| 306 | - | } | |
| 307 | - | if err == nil && ac != nil { | |
| 308 | - | if newFocus != oldFocus { | |
| 309 | - | d.exitCopyMode(ac) | |
| 310 | - | tb.mu.Lock() | |
| 311 | - | pl, ok := focusedPlacementLocked(tb) | |
| 312 | - | tb.mu.Unlock() | |
| 313 | - | if ok && pl.TitleBar.Height > 0 { | |
| 314 | - | d.refreshPaneTitleOnFocus(sess, newFocus) | |
| 315 | - | } | |
| 316 | - | } | |
| 317 | - | d.invalidateRender(sess, ac, true, "pane_actions.go") | |
| 346 | + | if err == nil && newFocus != oldFocus { | |
| 347 | + | markSnapshotDirty(sess) | |
| 318 | 348 | } | |
| 319 | 349 | if errors.Is(err, layout.ErrNoPane) { | |
| 320 | 350 | return errNoNeighbor | |
| @@ -10,25 +10,31 @@ import ( | |||||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Direction) error { | 12 | func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Direction) error { | ||
| 13 | + | target := resolveDaemonActionTarget(sess) | |||
| 14 | + | err := d.splitPaneAt(sess, target.tab, target.pane, dir) | |||
| 15 | + | if err == nil && ac != nil { | |||
| 16 | + | d.invalidateRender(sess, ac, true, "pane_actions.go") | |||
| 17 | + | } | |||
| 18 | + | return err | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func (d *Daemon) splitPaneAt(sess *session, tb *tab, target *pane, dir layout.Direction) error { | |||
| 13 | after := dir == layout.Right || dir == layout.Down | 22 | after := dir == layout.Right || dir == layout.Down | ||
| 14 | - | return d.spawnPaneOp(sess, ac, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error { | 23 | + | return d.spawnPaneOpAt(sess, tb, target, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error { |
| 15 | return tree.Split(oldFocus, dir, after, newID, area) | 24 | return tree.Split(oldFocus, dir, after, newID, area) | ||
| 16 | }) | 25 | }) | ||
| 17 | } | 26 | } | ||
| 18 | 27 | ||||
| 19 | - | func (d *Daemon) spawnPaneOp( | 28 | + | func (d *Daemon) spawnPaneOpAt( |
| 20 | sess *session, | 29 | sess *session, | ||
| 21 | - | ac *attachedClient, | 30 | + | tb *tab, |
| 31 | + | target *pane, | |||
| 22 | mutate func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error, | 32 | mutate func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error, | ||
| 23 | ) error { | 33 | ) error { | ||
| 24 | if d.ptys == nil { | 34 | if d.ptys == nil { | ||
| 25 | - | if ac != nil { | |||
| 26 | - | d.invalidateRender(sess, ac, true, "pane_actions.go") | |||
| 27 | - | } | |||
| 28 | return nil | 35 | return nil | ||
| 29 | } | 36 | } | ||
| 30 | - | tb := sess.activeTab() | 37 | + | if tb == nil || target == nil { |
| 31 | - | if tb == nil { | |||
| 32 | return layout.ErrNotFound | 38 | return layout.ErrNotFound | ||
| 33 | } | 39 | } | ||
| 34 | 40 | ||||
| @@ -43,7 +49,11 @@ func (d *Daemon) spawnPaneOp( | |||||
| 43 | tb.mu.Unlock() | 49 | tb.mu.Unlock() | ||
| 44 | return layout.ErrNotFound | 50 | return layout.ErrNotFound | ||
| 45 | } | 51 | } | ||
| 46 | - | oldFocus := tb.tree.Focus | 52 | + | oldFocus := target.id |
| 53 | + | if tb.panes[oldFocus] != target || !layout.ContainsLeaf(tb.tree.Root, oldFocus) { | |||
| 54 | + | tb.mu.Unlock() | |||
| 55 | + | return layout.ErrNotFound | |||
| 56 | + | } | |||
| 47 | newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID)) | 57 | newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID)) | ||
| 48 | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | 58 | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | ||
| 49 | if err := mutate(tb.tree, oldFocus, newID, area); err != nil { | 59 | if err := mutate(tb.tree, oldFocus, newID, area); err != nil { | ||
| @@ -103,9 +113,6 @@ func (d *Daemon) spawnPaneOp( | |||||
| 103 | 113 | ||||
| 104 | d.startPaneGoroutines(sess, tb, p) | 114 | d.startPaneGoroutines(sess, tb, p) | ||
| 105 | markSnapshotDirty(sess) | 115 | markSnapshotDirty(sess) | ||
| 106 | - | if ac != nil { | |||
| 107 | - | d.invalidateRender(sess, ac, true, "pane_actions.go") | |||
| 108 | - | } | |||
| 109 | return nil | 116 | return nil | ||
| 110 | } | 117 | } | ||
| 111 | 118 | ||||
| @@ -164,20 +171,34 @@ func placementContent(placements []layout.Placement, id layout.PaneID) domain.Re | |||||
| 164 | func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} } | 171 | func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} } | ||
| 165 | 172 | ||||
| 166 | func (d *Daemon) stackPane(sess *session, ac *attachedClient) error { | 173 | func (d *Daemon) stackPane(sess *session, ac *attachedClient) error { | ||
| 167 | - | return d.spawnPaneOp(sess, ac, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error { | 174 | + | target := resolveDaemonActionTarget(sess) |
| 175 | + | err := d.stackPaneAt(sess, target.tab, target.pane) | |||
| 176 | + | if err == nil && ac != nil { | |||
| 177 | + | d.invalidateRender(sess, ac, true, "pane_actions.go") | |||
| 178 | + | } | |||
| 179 | + | return err | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func (d *Daemon) stackPaneAt(sess *session, tb *tab, target *pane) error { | |||
| 183 | + | return d.spawnPaneOpAt(sess, tb, target, func(tree *layout.Tree, oldFocus, newID layout.PaneID, area domain.Rect) error { | |||
| 168 | return tree.StackNew(oldFocus, newID, area) | 184 | return tree.StackNew(oldFocus, newID, area) | ||
| 169 | }) | 185 | }) | ||
| 170 | } | 186 | } | ||
| 171 | 187 | ||||
| 172 | func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error { | 188 | func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error { | ||
| 189 | + | target := resolveDaemonActionTarget(sess) | |||
| 190 | + | err := d.toggleStackAt(sess, target.tab, target.pane) | |||
| 191 | + | if err == nil && ac != nil { | |||
| 192 | + | d.invalidateRender(sess, ac, true, "pane_actions.go") | |||
| 193 | + | } | |||
| 194 | + | return err | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func (d *Daemon) toggleStackAt(sess *session, tb *tab, target *pane) error { | |||
| 173 | if d.ptys == nil { | 198 | if d.ptys == nil { | ||
| 174 | - | if ac != nil { | |||
| 175 | - | d.invalidateRender(sess, ac, true, "pane_actions.go") | |||
| 176 | - | } | |||
| 177 | return nil | 199 | return nil | ||
| 178 | } | 200 | } | ||
| 179 | - | tb := sess.activeTab() | 201 | + | if tb == nil || target == nil { |
| 180 | - | if tb == nil { | |||
| 181 | return layout.ErrNotFound | 202 | return layout.ErrNotFound | ||
| 182 | } | 203 | } | ||
| 183 | tb.mu.Lock() | 204 | tb.mu.Lock() | ||
| @@ -185,16 +206,17 @@ func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error { | |||||
| 185 | tb.mu.Unlock() | 206 | tb.mu.Unlock() | ||
| 186 | return layout.ErrNotFound | 207 | return layout.ErrNotFound | ||
| 187 | } | 208 | } | ||
| 188 | - | err := tb.tree.ToggleStack(tb.tree.Focus, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | 209 | + | if tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) { |
| 210 | + | tb.mu.Unlock() | |||
| 211 | + | return layout.ErrNotFound | |||
| 212 | + | } | |||
| 213 | + | err := tb.tree.ToggleStack(target.id, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |||
| 189 | if err == nil { | 214 | if err == nil { | ||
| 190 | d.applyLayoutLocked(tb) | 215 | d.applyLayoutLocked(tb) | ||
| 191 | } | 216 | } | ||
| 192 | tb.mu.Unlock() | 217 | tb.mu.Unlock() | ||
| 193 | if err == nil { | 218 | if err == nil { | ||
| 194 | markSnapshotDirty(sess) | 219 | markSnapshotDirty(sess) | ||
| 195 | - | if ac != nil { | |||
| 196 | - | d.invalidateRender(sess, ac, true, "pane_actions.go") | |||
| 197 | - | } | |||
| 198 | } | 220 | } | ||
| 199 | return err | 221 | return err | ||
| 200 | } | 222 | } | ||
| @@ -247,8 +269,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||||
| 247 | if ac != nil { | 269 | if ac != nil { | ||
| 248 | ac.overlays.clearCopyModeForPane(p) | 270 | ac.overlays.clearCopyModeForPane(p) | ||
| 249 | } | 271 | } | ||
| 250 | - | d.closeTab(sess, tb, repaint) | 272 | + | return d.closeTab(sess, tb, repaint) |
| 251 | - | return nil | |||
| 252 | } | 273 | } | ||
| 253 | if err := tb.tree.Close(id); err != nil { | 274 | if err := tb.tree.Close(id); err != nil { | ||
| 254 | tb.mu.Unlock() | 275 | tb.mu.Unlock() | ||
| @@ -283,38 +304,47 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||||
| 283 | } | 304 | } | ||
| 284 | 305 | ||||
| 285 | func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction) error { | 306 | func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction) error { | ||
| 286 | - | tb := sess.activeTab() | 307 | + | target := resolveDaemonActionTarget(sess) |
| 287 | - | if tb == nil { | 308 | + | oldFocus := layout.PaneID("") |
| 309 | + | if target.pane != nil { | |||
| 310 | + | oldFocus = target.pane.id | |||
| 311 | + | } | |||
| 312 | + | err := d.focusDirAt(sess, target.tab, target.pane, dir) | |||
| 313 | + | if err == nil && ac != nil { | |||
| 314 | + | target.tab.mu.Lock() | |||
| 315 | + | newFocus := target.tab.tree.Focus | |||
| 316 | + | pl, hasPlacement := focusedPlacementLocked(target.tab) | |||
| 317 | + | target.tab.mu.Unlock() | |||
| 318 | + | if newFocus != oldFocus { | |||
| 319 | + | d.exitCopyMode(ac) | |||
| 320 | + | if hasPlacement && pl.TitleBar.Height > 0 { | |||
| 321 | + | d.refreshPaneTitleOnFocus(sess, newFocus) | |||
| 322 | + | } | |||
| 323 | + | } | |||
| 324 | + | d.invalidateRender(sess, ac, true, "pane_actions.go") | |||
| 325 | + | } | |||
| 326 | + | return err | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Direction) error { | |||
| 330 | + | if tb == nil || target == nil { | |||
| 288 | return layout.ErrNotFound | 331 | return layout.ErrNotFound | ||
| 289 | } | 332 | } | ||
| 290 | tb.mu.Lock() | 333 | tb.mu.Lock() | ||
| 291 | - | if tb.tree == nil { | 334 | + | if tb.tree == nil || tb.panes[target.id] != target || !layout.ContainsLeaf(tb.tree.Root, target.id) { |
| 292 | tb.mu.Unlock() | 335 | tb.mu.Unlock() | ||
| 293 | return layout.ErrNotFound | 336 | return layout.ErrNotFound | ||
| 294 | } | 337 | } | ||
| 295 | - | oldFocus := tb.tree.Focus | 338 | + | oldFocus := target.id |
| 339 | + | tb.tree.Focus = oldFocus | |||
| 296 | err := tb.tree.FocusDir(dir, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | 340 | err := tb.tree.FocusDir(dir, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | ||
| 297 | newFocus := tb.tree.Focus | 341 | newFocus := tb.tree.Focus | ||
| 298 | if err == nil { | 342 | if err == nil { | ||
| 299 | d.applyLayoutLocked(tb) | 343 | d.applyLayoutLocked(tb) | ||
| 300 | } | 344 | } | ||
| 301 | tb.mu.Unlock() | 345 | tb.mu.Unlock() | ||
| 302 | - | if err == nil { | 346 | + | if err == nil && newFocus != oldFocus { |
| 303 | - | if newFocus != oldFocus { | 347 | + | markSnapshotDirty(sess) |
| 304 | - | markSnapshotDirty(sess) | |||
| 305 | - | } | |||
| 306 | - | } | |||
| 307 | - | if err == nil && ac != nil { | |||
| 308 | - | if newFocus != oldFocus { | |||
| 309 | - | d.exitCopyMode(ac) | |||
| 310 | - | tb.mu.Lock() | |||
| 311 | - | pl, ok := focusedPlacementLocked(tb) | |||
| 312 | - | tb.mu.Unlock() | |||
| 313 | - | if ok && pl.TitleBar.Height > 0 { | |||
| 314 | - | d.refreshPaneTitleOnFocus(sess, newFocus) | |||
| 315 | - | } | |||
| 316 | - | } | |||
| 317 | - | d.invalidateRender(sess, ac, true, "pane_actions.go") | |||
| 318 | } | 348 | } | ||
| 319 | if errors.Is(err, layout.ErrNoPane) { | 349 | if errors.Is(err, layout.ErrNoPane) { | ||
| 320 | return errNoNeighbor | 350 | return errNoNeighbor | ||
M internal/usecase/daemon/render_coordinator_test.go +1 -1
| @@ -1185,7 +1185,7 @@ func TestProducerInvalidations(t *testing.T) { | |||
| 1185 | 1185 | sess.mu.Lock() | |
| 1186 | 1186 | tb := sess.tabs[1] | |
| 1187 | 1187 | sess.mu.Unlock() | |
| 1188 | - | d.closeTab(sess, tb, true) | |
| 1188 | + | require.NoError(t, d.closeTab(sess, tb, true)) | |
| 1189 | 1189 | }, | |
| 1190 | 1190 | }, | |
| 1191 | 1191 | { | |
| @@ -1185,7 +1185,7 @@ func TestProducerInvalidations(t *testing.T) { | |||||
| 1185 | sess.mu.Lock() | 1185 | sess.mu.Lock() | ||
| 1186 | tb := sess.tabs[1] | 1186 | tb := sess.tabs[1] | ||
| 1187 | sess.mu.Unlock() | 1187 | sess.mu.Unlock() | ||
| 1188 | - | d.closeTab(sess, tb, true) | 1188 | + | require.NoError(t, d.closeTab(sess, tb, true)) |
| 1189 | }, | 1189 | }, | ||
| 1190 | }, | 1190 | }, | ||
| 1191 | { | 1191 | { | ||
M internal/usecase/daemon/session.go +42 -6
| @@ -36,6 +36,11 @@ type session struct { | |||
| 36 | 36 | ctx context.Context | |
| 37 | 37 | cancel context.CancelFunc | |
| 38 | 38 | ||
| 39 | + | // dispatchMu serializes user-initiated mutating command dispatch (palette | |
| 40 | + | // and vev-cmd control) for this session, so a one-shot command cannot | |
| 41 | + | // resolve focus against one state and mutate a later one. Lock order: | |
| 42 | + | // dispatchMu strictly before d.mu, sess.mu, or any tab mu. | |
| 43 | + | dispatchMu sync.Mutex | |
| 39 | 44 | mu sync.Mutex // guards tabs, active, client, clipFiles, and clipboard queue state | |
| 40 | 45 | themeMu sync.Mutex | |
| 41 | 46 | tabs []*tab | |
| @@ -457,14 +462,41 @@ func (tb *tab) closeAllPanes() { | |||
| 457 | 462 | } | |
| 458 | 463 | } | |
| 459 | 464 | ||
| 465 | + | // tabChromeRows is the vertical chrome (tab bar + status bar) between a | |
| 466 | + | // client viewport and a tab's content area. | |
| 467 | + | const tabChromeRows = 2 | |
| 468 | + | ||
| 460 | 469 | func tabSize(clientSize domain.Size) domain.Size { | |
| 461 | 470 | if !clientSize.Valid() { | |
| 462 | 471 | clientSize = defaultSize | |
| 463 | 472 | } | |
| 464 | - | rows := max(clientSize.Rows-2, 1) | |
| 473 | + | rows := max(clientSize.Rows-tabChromeRows, 1) | |
| 465 | 474 | return domain.Size{Cols: clientSize.Cols, Rows: rows} | |
| 466 | 475 | } | |
| 467 | 476 | ||
| 477 | + | // fullViewportSize derives a full client-equivalent viewport from the active | |
| 478 | + | // tab's retained content size, for actions that need a viewport while no | |
| 479 | + | // client is attached. It falls back to defaultSize when no valid size exists. | |
| 480 | + | func (s *session) fullViewportSize() domain.Size { | |
| 481 | + | s.mu.Lock() | |
| 482 | + | var tb *tab | |
| 483 | + | if s.active >= 0 && s.active < len(s.tabs) { | |
| 484 | + | tb = s.tabs[s.active] | |
| 485 | + | } | |
| 486 | + | s.mu.Unlock() | |
| 487 | + | ||
| 488 | + | var content domain.Size | |
| 489 | + | if tb != nil { | |
| 490 | + | tb.mu.Lock() | |
| 491 | + | content = tb.size | |
| 492 | + | tb.mu.Unlock() | |
| 493 | + | } | |
| 494 | + | if !content.Valid() { | |
| 495 | + | return defaultSize | |
| 496 | + | } | |
| 497 | + | return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows} | |
| 498 | + | } | |
| 499 | + | ||
| 468 | 500 | func (d *Daemon) startTabGoroutines(sess *session, tb *tab) { | |
| 469 | 501 | tb.mu.Lock() | |
| 470 | 502 | panes := tb.panesSnapshot() | |
| @@ -705,11 +737,14 @@ func (s *session) persistRecordLocked(updatedAt int64) persist.Record { | |||
| 705 | 737 | return persist.Record{Name: s.name, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames} | |
| 706 | 738 | } | |
| 707 | 739 | ||
| 708 | - | func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |
| 740 | + | func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error { | |
| 741 | + | if sess == nil || tb == nil { | |
| 742 | + | return layout.ErrNotFound | |
| 743 | + | } | |
| 709 | 744 | d.mu.Lock() | |
| 710 | 745 | if d.sessions[sess.id] != sess { | |
| 711 | 746 | d.mu.Unlock() | |
| 712 | - | return | |
| 747 | + | return errors.New("daemon: session closed") | |
| 713 | 748 | } | |
| 714 | 749 | sess.mu.Lock() | |
| 715 | 750 | idx := -1 | |
| @@ -722,7 +757,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||
| 722 | 757 | if idx == -1 { | |
| 723 | 758 | sess.mu.Unlock() | |
| 724 | 759 | d.mu.Unlock() | |
| 725 | - | return | |
| 760 | + | return errors.New("tab not found") | |
| 726 | 761 | } | |
| 727 | 762 | if len(sess.tabs) == 1 { | |
| 728 | 763 | name := sess.name | |
| @@ -732,10 +767,10 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||
| 732 | 767 | d.log.Warn("closing last tab failed", "session", name, "err", err) | |
| 733 | 768 | d.reportError(sess, domain.UserErr(domain.NoticeSnapshotSaturated, | |
| 734 | 769 | "couldn't close tab: session state not yet saved; try again", err)) | |
| 735 | - | return | |
| 770 | + | return err | |
| 736 | 771 | } | |
| 737 | 772 | d.log.Info("tab closed", "session", name, "last", true) | |
| 738 | - | return | |
| 773 | + | return nil | |
| 739 | 774 | } | |
| 740 | 775 | ringing := tb.attention | |
| 741 | 776 | wasActive := idx == sess.active | |
| @@ -786,6 +821,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||
| 786 | 821 | if ringing { | |
| 787 | 822 | d.repaintAllAttachedClients() | |
| 788 | 823 | } | |
| 824 | + | return nil | |
| 789 | 825 | } | |
| 790 | 826 | ||
| 791 | 827 | // ptyReader drains child output into the VT screen and pokes the dirty channel | |
| @@ -36,6 +36,11 @@ type session struct { | |||||
| 36 | ctx context.Context | 36 | ctx context.Context | ||
| 37 | cancel context.CancelFunc | 37 | cancel context.CancelFunc | ||
| 38 | 38 | ||||
| 39 | + | // dispatchMu serializes user-initiated mutating command dispatch (palette | |||
| 40 | + | // and vev-cmd control) for this session, so a one-shot command cannot | |||
| 41 | + | // resolve focus against one state and mutate a later one. Lock order: | |||
| 42 | + | // dispatchMu strictly before d.mu, sess.mu, or any tab mu. | |||
| 43 | + | dispatchMu sync.Mutex | |||
| 39 | mu sync.Mutex // guards tabs, active, client, clipFiles, and clipboard queue state | 44 | mu sync.Mutex // guards tabs, active, client, clipFiles, and clipboard queue state | ||
| 40 | themeMu sync.Mutex | 45 | themeMu sync.Mutex | ||
| 41 | tabs []*tab | 46 | tabs []*tab | ||
| @@ -457,14 +462,41 @@ func (tb *tab) closeAllPanes() { | |||||
| 457 | } | 462 | } | ||
| 458 | } | 463 | } | ||
| 459 | 464 | ||||
| 465 | + | // tabChromeRows is the vertical chrome (tab bar + status bar) between a | |||
| 466 | + | // client viewport and a tab's content area. | |||
| 467 | + | const tabChromeRows = 2 | |||
| 468 | + | ||||
| 460 | func tabSize(clientSize domain.Size) domain.Size { | 469 | func tabSize(clientSize domain.Size) domain.Size { | ||
| 461 | if !clientSize.Valid() { | 470 | if !clientSize.Valid() { | ||
| 462 | clientSize = defaultSize | 471 | clientSize = defaultSize | ||
| 463 | } | 472 | } | ||
| 464 | - | rows := max(clientSize.Rows-2, 1) | 473 | + | rows := max(clientSize.Rows-tabChromeRows, 1) |
| 465 | return domain.Size{Cols: clientSize.Cols, Rows: rows} | 474 | return domain.Size{Cols: clientSize.Cols, Rows: rows} | ||
| 466 | } | 475 | } | ||
| 467 | 476 | ||||
| 477 | + | // fullViewportSize derives a full client-equivalent viewport from the active | |||
| 478 | + | // tab's retained content size, for actions that need a viewport while no | |||
| 479 | + | // client is attached. It falls back to defaultSize when no valid size exists. | |||
| 480 | + | func (s *session) fullViewportSize() domain.Size { | |||
| 481 | + | s.mu.Lock() | |||
| 482 | + | var tb *tab | |||
| 483 | + | if s.active >= 0 && s.active < len(s.tabs) { | |||
| 484 | + | tb = s.tabs[s.active] | |||
| 485 | + | } | |||
| 486 | + | s.mu.Unlock() | |||
| 487 | + | ||||
| 488 | + | var content domain.Size | |||
| 489 | + | if tb != nil { | |||
| 490 | + | tb.mu.Lock() | |||
| 491 | + | content = tb.size | |||
| 492 | + | tb.mu.Unlock() | |||
| 493 | + | } | |||
| 494 | + | if !content.Valid() { | |||
| 495 | + | return defaultSize | |||
| 496 | + | } | |||
| 497 | + | return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows} | |||
| 498 | + | } | |||
| 499 | + | ||||
| 468 | func (d *Daemon) startTabGoroutines(sess *session, tb *tab) { | 500 | func (d *Daemon) startTabGoroutines(sess *session, tb *tab) { | ||
| 469 | tb.mu.Lock() | 501 | tb.mu.Lock() | ||
| 470 | panes := tb.panesSnapshot() | 502 | panes := tb.panesSnapshot() | ||
| @@ -705,11 +737,14 @@ func (s *session) persistRecordLocked(updatedAt int64) persist.Record { | |||||
| 705 | return persist.Record{Name: s.name, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames} | 737 | return persist.Record{Name: s.name, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames} | ||
| 706 | } | 738 | } | ||
| 707 | 739 | ||||
| 708 | - | func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | 740 | + | func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error { |
| 741 | + | if sess == nil || tb == nil { | |||
| 742 | + | return layout.ErrNotFound | |||
| 743 | + | } | |||
| 709 | d.mu.Lock() | 744 | d.mu.Lock() | ||
| 710 | if d.sessions[sess.id] != sess { | 745 | if d.sessions[sess.id] != sess { | ||
| 711 | d.mu.Unlock() | 746 | d.mu.Unlock() | ||
| 712 | - | return | 747 | + | return errors.New("daemon: session closed") |
| 713 | } | 748 | } | ||
| 714 | sess.mu.Lock() | 749 | sess.mu.Lock() | ||
| 715 | idx := -1 | 750 | idx := -1 | ||
| @@ -722,7 +757,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||||
| 722 | if idx == -1 { | 757 | if idx == -1 { | ||
| 723 | sess.mu.Unlock() | 758 | sess.mu.Unlock() | ||
| 724 | d.mu.Unlock() | 759 | d.mu.Unlock() | ||
| 725 | - | return | 760 | + | return errors.New("tab not found") |
| 726 | } | 761 | } | ||
| 727 | if len(sess.tabs) == 1 { | 762 | if len(sess.tabs) == 1 { | ||
| 728 | name := sess.name | 763 | name := sess.name | ||
| @@ -732,10 +767,10 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||||
| 732 | d.log.Warn("closing last tab failed", "session", name, "err", err) | 767 | d.log.Warn("closing last tab failed", "session", name, "err", err) | ||
| 733 | d.reportError(sess, domain.UserErr(domain.NoticeSnapshotSaturated, | 768 | d.reportError(sess, domain.UserErr(domain.NoticeSnapshotSaturated, | ||
| 734 | "couldn't close tab: session state not yet saved; try again", err)) | 769 | "couldn't close tab: session state not yet saved; try again", err)) | ||
| 735 | - | return | 770 | + | return err |
| 736 | } | 771 | } | ||
| 737 | d.log.Info("tab closed", "session", name, "last", true) | 772 | d.log.Info("tab closed", "session", name, "last", true) | ||
| 738 | - | return | 773 | + | return nil |
| 739 | } | 774 | } | ||
| 740 | ringing := tb.attention | 775 | ringing := tb.attention | ||
| 741 | wasActive := idx == sess.active | 776 | wasActive := idx == sess.active | ||
| @@ -786,6 +821,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||||
| 786 | if ringing { | 821 | if ringing { | ||
| 787 | d.repaintAllAttachedClients() | 822 | d.repaintAllAttachedClients() | ||
| 788 | } | 823 | } | ||
| 824 | + | return nil | |||
| 789 | } | 825 | } | ||
| 790 | 826 | ||||
| 791 | // ptyReader drains child output into the VT screen and pokes the dirty channel | 827 | // ptyReader drains child output into the VT screen and pokes the dirty channel | ||
A internal/usecase/daemon/session_size_test.go +43 -0
| @@ -0,0 +1,43 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestFullViewportSize(t *testing.T) { | |
| 10 | + | tests := []struct { | |
| 11 | + | name string | |
| 12 | + | sess *session | |
| 13 | + | want domain.Size | |
| 14 | + | }{ | |
| 15 | + | { | |
| 16 | + | name: "derives from active tab content size", | |
| 17 | + | sess: &session{tabs: []*tab{{size: domain.Size{Cols: 120, Rows: 38}}}, active: 0}, | |
| 18 | + | want: domain.Size{Cols: 120, Rows: 40}, | |
| 19 | + | }, | |
| 20 | + | { | |
| 21 | + | name: "round-trips through tabSize", | |
| 22 | + | sess: &session{tabs: []*tab{{size: tabSize(domain.Size{Cols: 80, Rows: 24})}}, active: 0}, | |
| 23 | + | want: domain.Size{Cols: 80, Rows: 24}, | |
| 24 | + | }, | |
| 25 | + | { | |
| 26 | + | name: "falls back to defaultSize with no tabs", | |
| 27 | + | sess: &session{}, | |
| 28 | + | want: defaultSize, | |
| 29 | + | }, | |
| 30 | + | { | |
| 31 | + | name: "falls back to defaultSize with invalid tab size", | |
| 32 | + | sess: &session{tabs: []*tab{{}}, active: 0}, | |
| 33 | + | want: defaultSize, | |
| 34 | + | }, | |
| 35 | + | } | |
| 36 | + | for _, tt := range tests { | |
| 37 | + | t.Run(tt.name, func(t *testing.T) { | |
| 38 | + | if got := tt.sess.fullViewportSize(); got != tt.want { | |
| 39 | + | t.Fatalf("fullViewportSize() = %+v, want %+v", got, tt.want) | |
| 40 | + | } | |
| 41 | + | }) | |
| 42 | + | } | |
| 43 | + | } | |
| @@ -0,0 +1,43 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestFullViewportSize(t *testing.T) { | |||
| 10 | + | tests := []struct { | |||
| 11 | + | name string | |||
| 12 | + | sess *session | |||
| 13 | + | want domain.Size | |||
| 14 | + | }{ | |||
| 15 | + | { | |||
| 16 | + | name: "derives from active tab content size", | |||
| 17 | + | sess: &session{tabs: []*tab{{size: domain.Size{Cols: 120, Rows: 38}}}, active: 0}, | |||
| 18 | + | want: domain.Size{Cols: 120, Rows: 40}, | |||
| 19 | + | }, | |||
| 20 | + | { | |||
| 21 | + | name: "round-trips through tabSize", | |||
| 22 | + | sess: &session{tabs: []*tab{{size: tabSize(domain.Size{Cols: 80, Rows: 24})}}, active: 0}, | |||
| 23 | + | want: domain.Size{Cols: 80, Rows: 24}, | |||
| 24 | + | }, | |||
| 25 | + | { | |||
| 26 | + | name: "falls back to defaultSize with no tabs", | |||
| 27 | + | sess: &session{}, | |||
| 28 | + | want: defaultSize, | |||
| 29 | + | }, | |||
| 30 | + | { | |||
| 31 | + | name: "falls back to defaultSize with invalid tab size", | |||
| 32 | + | sess: &session{tabs: []*tab{{}}, active: 0}, | |||
| 33 | + | want: defaultSize, | |||
| 34 | + | }, | |||
| 35 | + | } | |||
| 36 | + | for _, tt := range tests { | |||
| 37 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 38 | + | if got := tt.sess.fullViewportSize(); got != tt.want { | |||
| 39 | + | t.Fatalf("fullViewportSize() = %+v, want %+v", got, tt.want) | |||
| 40 | + | } | |||
| 41 | + | }) | |||
| 42 | + | } | |||
| 43 | + | } | |||
M internal/usecase/daemon/session_test.go +2 -2
| @@ -954,7 +954,7 @@ func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) { | |||
| 954 | 954 | require.NoError(t, d.renameTab(sess, sess.tabs[i], name)) | |
| 955 | 955 | } | |
| 956 | 956 | ||
| 957 | - | d.closeTab(sess, sess.tabs[tt.closeIndex], false) | |
| 957 | + | require.NoError(t, d.closeTab(sess, sess.tabs[tt.closeIndex], false)) | |
| 958 | 958 | records, err := d.persist.LoadAll() | |
| 959 | 959 | require.NoError(t, err) | |
| 960 | 960 | require.Len(t, records, 1) | |
| @@ -980,7 +980,7 @@ func TestCloseActiveTabActivatesDestinationFloatingPane(t *testing.T) { | |||
| 980 | 980 | first.mu.Unlock() | |
| 981 | 981 | closeFloatingPane(stale) | |
| 982 | 982 | ||
| 983 | - | d.closeTab(sess, closing, false) | |
| 983 | + | require.NoError(t, d.closeTab(sess, closing, false)) | |
| 984 | 984 | ||
| 985 | 985 | require.Same(t, first, sess.activeTab()) | |
| 986 | 986 | requireFloatingInitialized(t, first) | |
| @@ -954,7 +954,7 @@ func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) { | |||||
| 954 | require.NoError(t, d.renameTab(sess, sess.tabs[i], name)) | 954 | require.NoError(t, d.renameTab(sess, sess.tabs[i], name)) | ||
| 955 | } | 955 | } | ||
| 956 | 956 | ||||
| 957 | - | d.closeTab(sess, sess.tabs[tt.closeIndex], false) | 957 | + | require.NoError(t, d.closeTab(sess, sess.tabs[tt.closeIndex], false)) |
| 958 | records, err := d.persist.LoadAll() | 958 | records, err := d.persist.LoadAll() | ||
| 959 | require.NoError(t, err) | 959 | require.NoError(t, err) | ||
| 960 | require.Len(t, records, 1) | 960 | require.Len(t, records, 1) | ||
| @@ -980,7 +980,7 @@ func TestCloseActiveTabActivatesDestinationFloatingPane(t *testing.T) { | |||||
| 980 | first.mu.Unlock() | 980 | first.mu.Unlock() | ||
| 981 | closeFloatingPane(stale) | 981 | closeFloatingPane(stale) | ||
| 982 | 982 | ||||
| 983 | - | d.closeTab(sess, closing, false) | 983 | + | require.NoError(t, d.closeTab(sess, closing, false)) |
| 984 | 984 | ||||
| 985 | require.Same(t, first, sess.activeTab()) | 985 | require.Same(t, first, sess.activeTab()) | ||
| 986 | requireFloatingInitialized(t, first) | 986 | requireFloatingInitialized(t, first) | ||
M internal/usecase/palette/model.go +1 -1
| @@ -51,7 +51,7 @@ func New(results []Result) *Model { | |||
| 51 | 51 | return m | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | - | func NewRegistry() *Model { return New(CommandResults(command.Registry())) } | |
| 54 | + | func NewRegistry() *Model { return New(CommandResults(command.PaletteRegistry())) } | |
| 55 | 55 | ||
| 56 | 56 | func DefaultRenderStyles() RenderStyles { | |
| 57 | 57 | selection := renderer.DefaultStyle() | |
| @@ -51,7 +51,7 @@ func New(results []Result) *Model { | |||||
| 51 | return m | 51 | return m | ||
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
| 54 | - | func NewRegistry() *Model { return New(CommandResults(command.Registry())) } | 54 | + | func NewRegistry() *Model { return New(CommandResults(command.PaletteRegistry())) } |
| 55 | 55 | ||||
| 56 | func DefaultRenderStyles() RenderStyles { | 56 | func DefaultRenderStyles() RenderStyles { | ||
| 57 | selection := renderer.DefaultStyle() | 57 | selection := renderer.DefaultStyle() | ||
M main.go +1 -1
| @@ -11,7 +11,7 @@ import ( | |||
| 11 | 11 | func main() { | |
| 12 | 12 | if err := app.Run(os.Args[1:]); err != nil { | |
| 13 | 13 | fmt.Fprintln(os.Stderr, cliError(err)) | |
| 14 | - | os.Exit(1) | |
| 14 | + | os.Exit(app.ExitCode(err)) | |
| 15 | 15 | } | |
| 16 | 16 | } | |
| 17 | 17 | ||
| @@ -11,7 +11,7 @@ import ( | |||||
| 11 | func main() { | 11 | func main() { | ||
| 12 | if err := app.Run(os.Args[1:]); err != nil { | 12 | if err := app.Run(os.Args[1:]); err != nil { | ||
| 13 | fmt.Fprintln(os.Stderr, cliError(err)) | 13 | fmt.Fprintln(os.Stderr, cliError(err)) | ||
| 14 | - | os.Exit(1) | 14 | + | os.Exit(app.ExitCode(err)) |
| 15 | } | 15 | } | ||
| 16 | } | 16 | } | ||
| 17 | 17 | ||||