fix(control): honor explicit self target
- commit
- b2b8267511973963f1891e99379e9f7ce13e58d5
- author
- brice · · 2026-07-24 05:16 UTC
- parent
- 1b18dd9
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +148 -15
M internal/app/cmd.go +1 -0
| @@ -139,6 +139,7 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) | |||
| 139 | 139 | } | |
| 140 | 140 | request := ports.CommandRequest{ | |
| 141 | 141 | Version: ports.ProtocolVersion, | |
| 142 | + | Self: invocation.self, | |
| 142 | 143 | Slug: invocation.slug, | |
| 143 | 144 | Args: invocation.args, | |
| 144 | 145 | TargetSession: invocation.session, | |
| @@ -139,6 +139,7 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) | |||||
| 139 | } | 139 | } | ||
| 140 | request := ports.CommandRequest{ | 140 | request := ports.CommandRequest{ | ||
| 141 | Version: ports.ProtocolVersion, | 141 | Version: ports.ProtocolVersion, | ||
| 142 | + | Self: invocation.self, | |||
| 142 | Slug: invocation.slug, | 143 | Slug: invocation.slug, | ||
| 143 | Args: invocation.args, | 144 | Args: invocation.args, | ||
| 144 | TargetSession: invocation.session, | 145 | TargetSession: invocation.session, | ||
M internal/app/cmd_test.go +20 -1
| @@ -117,7 +117,7 @@ func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) { | |||
| 117 | 117 | if err != nil { | |
| 118 | 118 | t.Fatalf("decode request: %v", err) | |
| 119 | 119 | } | |
| 120 | - | if transport.sent[0].Type != ports.MsgCommand || req.Slug != "split-right" || req.TargetSession != "old" || req.TargetTab != "t_abc" || req.TargetPane != "p_def" { | |
| 120 | + | if transport.sent[0].Type != ports.MsgCommand || req.Slug != "split-right" || !req.Self || req.TargetSession != "old" || req.TargetTab != "t_abc" || req.TargetPane != "p_def" { | |
| 121 | 121 | t.Fatalf("request = type %d %+v", transport.sent[0].Type, req) | |
| 122 | 122 | } | |
| 123 | 123 | if out.String() != "done\n" { | |
| @@ -125,6 +125,25 @@ func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) { | |||
| 125 | 125 | } | |
| 126 | 126 | } | |
| 127 | 127 | ||
| 128 | + | func TestRunCmdVEVWithoutSelfUsesIDsOnlyAsSessionLocator(t *testing.T) { | |
| 129 | + | transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}} | |
| 130 | + | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{ | |
| 131 | + | stdout: io.Discard, | |
| 132 | + | getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" }, | |
| 133 | + | dial: func(context.Context, string) (ports.Transport, error) { return transport, nil }, | |
| 134 | + | }) | |
| 135 | + | if err != nil { | |
| 136 | + | t.Fatalf("runCmdWithDeps: %v", err) | |
| 137 | + | } | |
| 138 | + | req, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload) | |
| 139 | + | if err != nil { | |
| 140 | + | t.Fatalf("decode request: %v", err) | |
| 141 | + | } | |
| 142 | + | if req.Self || req.TargetSession != "old" || req.TargetTab != "t_abc" || req.TargetPane != "p_def" { | |
| 143 | + | t.Fatalf("VEV locator request = %+v", req) | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 128 | 147 | func TestRunCmdExplicitSessionDoesNotUseEnvIDsWithoutSelf(t *testing.T) { | |
| 129 | 148 | transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}} | |
| 130 | 149 | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "new-tab", session: "current"}, cmdDeps{ | |
| @@ -117,7 +117,7 @@ func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) { | |||||
| 117 | if err != nil { | 117 | if err != nil { | ||
| 118 | t.Fatalf("decode request: %v", err) | 118 | t.Fatalf("decode request: %v", err) | ||
| 119 | } | 119 | } | ||
| 120 | - | if transport.sent[0].Type != ports.MsgCommand || req.Slug != "split-right" || req.TargetSession != "old" || req.TargetTab != "t_abc" || req.TargetPane != "p_def" { | 120 | + | if transport.sent[0].Type != ports.MsgCommand || req.Slug != "split-right" || !req.Self || req.TargetSession != "old" || req.TargetTab != "t_abc" || req.TargetPane != "p_def" { |
| 121 | t.Fatalf("request = type %d %+v", transport.sent[0].Type, req) | 121 | t.Fatalf("request = type %d %+v", transport.sent[0].Type, req) | ||
| 122 | } | 122 | } | ||
| 123 | if out.String() != "done\n" { | 123 | if out.String() != "done\n" { | ||
| @@ -125,6 +125,25 @@ func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) { | |||||
| 125 | } | 125 | } | ||
| 126 | } | 126 | } | ||
| 127 | 127 | ||||
| 128 | + | func TestRunCmdVEVWithoutSelfUsesIDsOnlyAsSessionLocator(t *testing.T) { | |||
| 129 | + | transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}} | |||
| 130 | + | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{ | |||
| 131 | + | stdout: io.Discard, | |||
| 132 | + | getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" }, | |||
| 133 | + | dial: func(context.Context, string) (ports.Transport, error) { return transport, nil }, | |||
| 134 | + | }) | |||
| 135 | + | if err != nil { | |||
| 136 | + | t.Fatalf("runCmdWithDeps: %v", err) | |||
| 137 | + | } | |||
| 138 | + | req, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload) | |||
| 139 | + | if err != nil { | |||
| 140 | + | t.Fatalf("decode request: %v", err) | |||
| 141 | + | } | |||
| 142 | + | if req.Self || req.TargetSession != "old" || req.TargetTab != "t_abc" || req.TargetPane != "p_def" { | |||
| 143 | + | t.Fatalf("VEV locator request = %+v", req) | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | ||||
| 128 | func TestRunCmdExplicitSessionDoesNotUseEnvIDsWithoutSelf(t *testing.T) { | 147 | func TestRunCmdExplicitSessionDoesNotUseEnvIDsWithoutSelf(t *testing.T) { | ||
| 129 | transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}} | 148 | transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}} | ||
| 130 | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "new-tab", session: "current"}, cmdDeps{ | 149 | err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "new-tab", session: "current"}, cmdDeps{ | ||
M internal/ports/wire.go +11 -0
| @@ -194,6 +194,7 @@ type Kill struct { | |||
| 194 | 194 | // stay first so a future payload layout can still be rejected cleanly. | |
| 195 | 195 | type CommandRequest struct { | |
| 196 | 196 | Version uint16 | |
| 197 | + | Self bool | |
| 197 | 198 | Slug string | |
| 198 | 199 | Args []string | |
| 199 | 200 | TargetSession string | |
| @@ -889,6 +890,11 @@ func UnmarshalKill(b []byte) (Kill, error) { | |||
| 889 | 890 | func MarshalCommandRequest(m CommandRequest) []byte { | |
| 890 | 891 | w := payloadWriter{} | |
| 891 | 892 | w.putUint16(m.Version) | |
| 893 | + | if m.Self { | |
| 894 | + | w.putUint8(1) | |
| 895 | + | } else { | |
| 896 | + | w.putUint8(0) | |
| 897 | + | } | |
| 892 | 898 | w.putString(m.Slug) | |
| 893 | 899 | w.putUint16(uint16(len(m.Args))) | |
| 894 | 900 | for _, arg := range m.Args { | |
| @@ -914,6 +920,11 @@ func UnmarshalCommandRequest(b []byte) (CommandRequest, error) { | |||
| 914 | 920 | if m.Version, err = r.getUint16(); err != nil { | |
| 915 | 921 | return CommandRequest{}, err | |
| 916 | 922 | } | |
| 923 | + | selfFlag, err := r.getUint8() | |
| 924 | + | if err != nil { | |
| 925 | + | return CommandRequest{}, err | |
| 926 | + | } | |
| 927 | + | m.Self = selfFlag != 0 | |
| 917 | 928 | if m.Slug, err = r.getString(); err != nil { | |
| 918 | 929 | return CommandRequest{}, err | |
| 919 | 930 | } | |
| @@ -194,6 +194,7 @@ type Kill struct { | |||||
| 194 | // stay first so a future payload layout can still be rejected cleanly. | 194 | // stay first so a future payload layout can still be rejected cleanly. | ||
| 195 | type CommandRequest struct { | 195 | type CommandRequest struct { | ||
| 196 | Version uint16 | 196 | Version uint16 | ||
| 197 | + | Self bool | |||
| 197 | Slug string | 198 | Slug string | ||
| 198 | Args []string | 199 | Args []string | ||
| 199 | TargetSession string | 200 | TargetSession string | ||
| @@ -889,6 +890,11 @@ func UnmarshalKill(b []byte) (Kill, error) { | |||||
| 889 | func MarshalCommandRequest(m CommandRequest) []byte { | 890 | func MarshalCommandRequest(m CommandRequest) []byte { | ||
| 890 | w := payloadWriter{} | 891 | w := payloadWriter{} | ||
| 891 | w.putUint16(m.Version) | 892 | w.putUint16(m.Version) | ||
| 893 | + | if m.Self { | |||
| 894 | + | w.putUint8(1) | |||
| 895 | + | } else { | |||
| 896 | + | w.putUint8(0) | |||
| 897 | + | } | |||
| 892 | w.putString(m.Slug) | 898 | w.putString(m.Slug) | ||
| 893 | w.putUint16(uint16(len(m.Args))) | 899 | w.putUint16(uint16(len(m.Args))) | ||
| 894 | for _, arg := range m.Args { | 900 | for _, arg := range m.Args { | ||
| @@ -914,6 +920,11 @@ func UnmarshalCommandRequest(b []byte) (CommandRequest, error) { | |||||
| 914 | if m.Version, err = r.getUint16(); err != nil { | 920 | if m.Version, err = r.getUint16(); err != nil { | ||
| 915 | return CommandRequest{}, err | 921 | return CommandRequest{}, err | ||
| 916 | } | 922 | } | ||
| 923 | + | selfFlag, err := r.getUint8() | |||
| 924 | + | if err != nil { | |||
| 925 | + | return CommandRequest{}, err | |||
| 926 | + | } | |||
| 927 | + | m.Self = selfFlag != 0 | |||
| 917 | if m.Slug, err = r.getString(); err != nil { | 928 | if m.Slug, err = r.getString(); err != nil { | ||
| 918 | return CommandRequest{}, err | 929 | return CommandRequest{}, err | ||
| 919 | } | 930 | } | ||
M internal/ports/wire_test.go +4 -1
| @@ -461,6 +461,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 461 | 461 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | |
| 462 | 462 | want: []byte{ | |
| 463 | 463 | 0x00, 0x12, | |
| 464 | + | 0x00, | |
| 464 | 465 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | |
| 465 | 466 | 0x00, 0x00, | |
| 466 | 467 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| @@ -471,6 +472,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 471 | 472 | name: "full", | |
| 472 | 473 | msg: CommandRequest{ | |
| 473 | 474 | Version: ProtocolVersion, | |
| 475 | + | Self: true, | |
| 474 | 476 | Slug: "toast", | |
| 475 | 477 | Args: []string{"-l", "warn", "tests KO"}, | |
| 476 | 478 | TargetSession: "dev", | |
| @@ -480,6 +482,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 480 | 482 | }, | |
| 481 | 483 | want: []byte{ | |
| 482 | 484 | 0x00, 0x12, | |
| 485 | + | 0x01, | |
| 483 | 486 | 0x00, 0x05, 't', 'o', 'a', 's', 't', | |
| 484 | 487 | 0x00, 0x03, | |
| 485 | 488 | 0x00, 0x00, 0x00, 0x02, '-', 'l', | |
| @@ -515,7 +518,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 515 | 518 | ||
| 516 | 519 | func TestCommandRequestRejectsImpossibleArgumentCount(t *testing.T) { | |
| 517 | 520 | payload := MarshalCommandRequest(CommandRequest{Version: ProtocolVersion, Slug: "toast"}) | |
| 518 | - | argCountOffset := 2 + 2 + len("toast") | |
| 521 | + | argCountOffset := 2 + 1 + 2 + len("toast") | |
| 519 | 522 | payload[argCountOffset] = 0xff | |
| 520 | 523 | payload[argCountOffset+1] = 0xff | |
| 521 | 524 | ||
| @@ -461,6 +461,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||||
| 461 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | 461 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | ||
| 462 | want: []byte{ | 462 | want: []byte{ | ||
| 463 | 0x00, 0x12, | 463 | 0x00, 0x12, | ||
| 464 | + | 0x00, | |||
| 464 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | 465 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | ||
| 465 | 0x00, 0x00, | 466 | 0x00, 0x00, | ||
| 466 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 467 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| @@ -471,6 +472,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||||
| 471 | name: "full", | 472 | name: "full", | ||
| 472 | msg: CommandRequest{ | 473 | msg: CommandRequest{ | ||
| 473 | Version: ProtocolVersion, | 474 | Version: ProtocolVersion, | ||
| 475 | + | Self: true, | |||
| 474 | Slug: "toast", | 476 | Slug: "toast", | ||
| 475 | Args: []string{"-l", "warn", "tests KO"}, | 477 | Args: []string{"-l", "warn", "tests KO"}, | ||
| 476 | TargetSession: "dev", | 478 | TargetSession: "dev", | ||
| @@ -480,6 +482,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||||
| 480 | }, | 482 | }, | ||
| 481 | want: []byte{ | 483 | want: []byte{ | ||
| 482 | 0x00, 0x12, | 484 | 0x00, 0x12, | ||
| 485 | + | 0x01, | |||
| 483 | 0x00, 0x05, 't', 'o', 'a', 's', 't', | 486 | 0x00, 0x05, 't', 'o', 'a', 's', 't', | ||
| 484 | 0x00, 0x03, | 487 | 0x00, 0x03, | ||
| 485 | 0x00, 0x00, 0x00, 0x02, '-', 'l', | 488 | 0x00, 0x00, 0x00, 0x02, '-', 'l', | ||
| @@ -515,7 +518,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||||
| 515 | 518 | ||||
| 516 | func TestCommandRequestRejectsImpossibleArgumentCount(t *testing.T) { | 519 | func TestCommandRequestRejectsImpossibleArgumentCount(t *testing.T) { | ||
| 517 | payload := MarshalCommandRequest(CommandRequest{Version: ProtocolVersion, Slug: "toast"}) | 520 | payload := MarshalCommandRequest(CommandRequest{Version: ProtocolVersion, Slug: "toast"}) | ||
| 518 | - | argCountOffset := 2 + 2 + len("toast") | 521 | + | argCountOffset := 2 + 1 + 2 + len("toast") |
| 519 | payload[argCountOffset] = 0xff | 522 | payload[argCountOffset] = 0xff | ||
| 520 | payload[argCountOffset+1] = 0xff | 523 | payload[argCountOffset+1] = 0xff | ||
| 521 | 524 | ||||
M internal/usecase/daemon/control.go +22 -3
| @@ -42,7 +42,7 @@ func (d *Daemon) dispatchCommand(request ports.CommandRequest) ports.CommandResu | |||
| 42 | 42 | if !cmd.Scriptable || cmd.Control == nil { | |
| 43 | 43 | return commandFailure(ports.ErrNotScriptable, request.Slug+" requires an attached client") | |
| 44 | 44 | } | |
| 45 | - | if cmd.Target == command.TargetNone { | |
| 45 | + | if cmd.Target == command.TargetNone && !request.Self { | |
| 46 | 46 | return d.runControl(cmd, controlExec{d: d}, request) | |
| 47 | 47 | } | |
| 48 | 48 | ||
| @@ -53,7 +53,7 @@ func (d *Daemon) dispatchCommand(request ports.CommandRequest) ports.CommandResu | |||
| 53 | 53 | sess.dispatchMu.Lock() | |
| 54 | 54 | defer sess.dispatchMu.Unlock() | |
| 55 | 55 | ||
| 56 | - | tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request.TargetTab, request.TargetPane) | |
| 56 | + | tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request) | |
| 57 | 57 | if code != 0 { | |
| 58 | 58 | return commandFailure(code, text) | |
| 59 | 59 | } | |
| @@ -87,6 +87,9 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u | |||
| 87 | 87 | defer d.mu.Unlock() | |
| 88 | 88 | ||
| 89 | 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 | + | } | |
| 90 | 93 | if request.TargetSession != "" && request.TargetTab == "" && request.TargetPane == "" { | |
| 91 | 94 | if named == nil { | |
| 92 | 95 | return nil, ports.ErrNoSuchTarget, "no such session: " + request.TargetSession | |
| @@ -135,11 +138,27 @@ func (s *session) containsStableIDs(tabID, paneID string) bool { | |||
| 135 | 138 | return false | |
| 136 | 139 | } | |
| 137 | 140 | ||
| 138 | - | func resolveControlTarget(sess *session, kind command.TargetKind, _, _ string) (*tab, *pane, uint16, string) { | |
| 141 | + | func resolveControlTarget(sess *session, kind command.TargetKind, request ports.CommandRequest) (*tab, *pane, uint16, string) { | |
| 139 | 142 | sess.mu.Lock() | |
| 140 | 143 | tabs := append([]*tab(nil), sess.tabs...) | |
| 141 | 144 | active := sess.active | |
| 142 | 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 | + | } | |
| 143 | 162 | if len(tabs) == 0 || active < 0 || active >= len(tabs) { | |
| 144 | 163 | return nil, nil, ports.ErrNoSuchTarget, "target session has no active tab" | |
| 145 | 164 | } | |
| @@ -42,7 +42,7 @@ func (d *Daemon) dispatchCommand(request ports.CommandRequest) ports.CommandResu | |||||
| 42 | if !cmd.Scriptable || cmd.Control == nil { | 42 | if !cmd.Scriptable || cmd.Control == nil { | ||
| 43 | return commandFailure(ports.ErrNotScriptable, request.Slug+" requires an attached client") | 43 | return commandFailure(ports.ErrNotScriptable, request.Slug+" requires an attached client") | ||
| 44 | } | 44 | } | ||
| 45 | - | if cmd.Target == command.TargetNone { | 45 | + | if cmd.Target == command.TargetNone && !request.Self { |
| 46 | return d.runControl(cmd, controlExec{d: d}, request) | 46 | return d.runControl(cmd, controlExec{d: d}, request) | ||
| 47 | } | 47 | } | ||
| 48 | 48 | ||||
| @@ -53,7 +53,7 @@ func (d *Daemon) dispatchCommand(request ports.CommandRequest) ports.CommandResu | |||||
| 53 | sess.dispatchMu.Lock() | 53 | sess.dispatchMu.Lock() | ||
| 54 | defer sess.dispatchMu.Unlock() | 54 | defer sess.dispatchMu.Unlock() | ||
| 55 | 55 | ||||
| 56 | - | tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request.TargetTab, request.TargetPane) | 56 | + | tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request) |
| 57 | if code != 0 { | 57 | if code != 0 { | ||
| 58 | return commandFailure(code, text) | 58 | return commandFailure(code, text) | ||
| 59 | } | 59 | } | ||
| @@ -87,6 +87,9 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u | |||||
| 87 | defer d.mu.Unlock() | 87 | defer d.mu.Unlock() | ||
| 88 | 88 | ||||
| 89 | named := d.findByNameLocked(request.TargetSession) | 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 | + | } | |||
| 90 | if request.TargetSession != "" && request.TargetTab == "" && request.TargetPane == "" { | 93 | if request.TargetSession != "" && request.TargetTab == "" && request.TargetPane == "" { | ||
| 91 | if named == nil { | 94 | if named == nil { | ||
| 92 | return nil, ports.ErrNoSuchTarget, "no such session: " + request.TargetSession | 95 | return nil, ports.ErrNoSuchTarget, "no such session: " + request.TargetSession | ||
| @@ -135,11 +138,27 @@ func (s *session) containsStableIDs(tabID, paneID string) bool { | |||||
| 135 | return false | 138 | return false | ||
| 136 | } | 139 | } | ||
| 137 | 140 | ||||
| 138 | - | func resolveControlTarget(sess *session, kind command.TargetKind, _, _ string) (*tab, *pane, uint16, string) { | 141 | + | func resolveControlTarget(sess *session, kind command.TargetKind, request ports.CommandRequest) (*tab, *pane, uint16, string) { |
| 139 | sess.mu.Lock() | 142 | sess.mu.Lock() | ||
| 140 | tabs := append([]*tab(nil), sess.tabs...) | 143 | tabs := append([]*tab(nil), sess.tabs...) | ||
| 141 | active := sess.active | 144 | active := sess.active | ||
| 142 | sess.mu.Unlock() | 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 | + | } | |||
| 143 | if len(tabs) == 0 || active < 0 || active >= len(tabs) { | 162 | if len(tabs) == 0 || active < 0 || active >= len(tabs) { | ||
| 144 | return nil, nil, ports.ErrNoSuchTarget, "target session has no active tab" | 163 | return nil, nil, ports.ErrNoSuchTarget, "target session has no active tab" | ||
| 145 | } | 164 | } | ||
M internal/usecase/daemon/control_test.go +90 -10
| @@ -97,6 +97,15 @@ func TestHandleCommandDispatchAndTargetErrors(t *testing.T) { | |||
| 97 | 97 | addControlSession(d, "one", "t_one", "p_one") | |
| 98 | 98 | addControlSession(d, "two", "t_two", "p_two") | |
| 99 | 99 | }, request: ports.CommandRequest{Slug: "split-right", TargetSession: "one", TargetTab: "t_two", TargetPane: "p_two"}, code: ports.ErrNoSuchTarget}, | |
| 100 | + | {name: "self requires both IDs", arrange: func(d *Daemon) { | |
| 101 | + | addControlSession(d, "work", "t_work", "p_work") | |
| 102 | + | }, request: ports.CommandRequest{Slug: "split-right", Self: true, TargetSession: "work", TargetTab: "t_work"}, code: ports.ErrNoSuchTarget}, | |
| 103 | + | {name: "self rejects pane from another tab", arrange: func(d *Daemon) { | |
| 104 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 105 | + | other := newTabWithStableID("t_other", "p_other", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 106 | + | other.ctx, other.cancel = context.WithCancel(d.serveCtx) | |
| 107 | + | sess.tabs = append(sess.tabs, other) | |
| 108 | + | }, request: ports.CommandRequest{Slug: "split-right", Self: true, TargetSession: "work", TargetTab: "t_work", TargetPane: "p_other"}, code: ports.ErrNoSuchTarget}, | |
| 100 | 109 | } | |
| 101 | 110 | ||
| 102 | 111 | for _, tt := range tests { | |
| @@ -192,6 +201,63 @@ func TestHandleCommandStableIDsDoNotRedirectSplitFromCurrentFocus(t *testing.T) | |||
| 192 | 201 | require.Equal(t, 1, secondPaneCount, "stable IDs are only a session locator") | |
| 193 | 202 | } | |
| 194 | 203 | ||
| 204 | + | func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) { | |
| 205 | + | factory := &controlPTYFactory{} | |
| 206 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 207 | + | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |
| 208 | + | sess := addControlSession(d, "work", "t_active", "p_active") | |
| 209 | + | active := sess.tabs[0] | |
| 210 | + | invoking := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 211 | + | invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx) | |
| 212 | + | sess.mu.Lock() | |
| 213 | + | sess.tabs = append(sess.tabs, invoking) | |
| 214 | + | sess.active = 0 | |
| 215 | + | sess.mu.Unlock() | |
| 216 | + | ||
| 217 | + | rename := sendCommand(t, d, ports.CommandRequest{ | |
| 218 | + | Slug: "rename-tab", Args: []string{"invoking"}, Self: true, | |
| 219 | + | TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking", | |
| 220 | + | }) | |
| 221 | + | require.True(t, rename.OK, rename.Text) | |
| 222 | + | active.mu.Lock() | |
| 223 | + | require.Empty(t, active.name) | |
| 224 | + | active.mu.Unlock() | |
| 225 | + | invoking.mu.Lock() | |
| 226 | + | require.Equal(t, "invoking", invoking.name) | |
| 227 | + | invoking.mu.Unlock() | |
| 228 | + | ||
| 229 | + | split := sendCommand(t, d, ports.CommandRequest{ | |
| 230 | + | Slug: "split-right", Self: true, | |
| 231 | + | TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking", | |
| 232 | + | }) | |
| 233 | + | require.True(t, split.OK, split.Text) | |
| 234 | + | active.mu.Lock() | |
| 235 | + | require.Len(t, active.panes, 1) | |
| 236 | + | active.mu.Unlock() | |
| 237 | + | invoking.mu.Lock() | |
| 238 | + | require.Len(t, invoking.panes, 2) | |
| 239 | + | invoking.mu.Unlock() | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | func TestHandleCommandSelfListPanesUsesInvokingTab(t *testing.T) { | |
| 243 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 244 | + | sess := addControlSession(d, "work", "t_active", "p_active") | |
| 245 | + | invoking := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 246 | + | invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx) | |
| 247 | + | sess.mu.Lock() | |
| 248 | + | sess.tabs = append(sess.tabs, invoking) | |
| 249 | + | sess.active = 0 | |
| 250 | + | sess.mu.Unlock() | |
| 251 | + | ||
| 252 | + | result := sendCommand(t, d, ports.CommandRequest{ | |
| 253 | + | Slug: "list-panes", Self: true, JSON: true, | |
| 254 | + | TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking", | |
| 255 | + | }) | |
| 256 | + | require.True(t, result.OK, result.Text) | |
| 257 | + | require.Contains(t, result.Output, "p_invoking") | |
| 258 | + | require.NotContains(t, result.Output, "p_active") | |
| 259 | + | } | |
| 260 | + | ||
| 195 | 261 | func TestHandleCommandRenameSessionRejectsInvalidNameAsCommandArgs(t *testing.T) { | |
| 196 | 262 | d := newTestDaemon(t, nil, stubClock{}) | |
| 197 | 263 | addControlSession(d, "work", "t_work", "p_work") | |
| @@ -322,14 +388,23 @@ func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) { | |||
| 322 | 388 | require.Equal(t, true, decoded[0]["focused"]) | |
| 323 | 389 | } | |
| 324 | 390 | ||
| 325 | - | func TestHandleCommandSerializesResolutionAndExecutionPerSession(t *testing.T) { | |
| 391 | + | func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) { | |
| 326 | 392 | factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})} | |
| 327 | 393 | d := newTestDaemon(t, factory, stubClock{}) | |
| 328 | 394 | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |
| 329 | - | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 330 | - | original := sess.tabs[0].tree.Focus | |
| 395 | + | sess := addControlSession(d, "work", "t_active", "p_active") | |
| 396 | + | target := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 397 | + | target.ctx, target.cancel = context.WithCancel(d.serveCtx) | |
| 398 | + | sess.mu.Lock() | |
| 399 | + | sess.tabs = append(sess.tabs, target) | |
| 400 | + | sess.active = 0 | |
| 401 | + | sess.mu.Unlock() | |
| 402 | + | original := target.tree.Focus | |
| 331 | 403 | ||
| 332 | - | firstFrame := commandFrame(ports.CommandRequest{Slug: "split-right", TargetSession: "work"}) | |
| 404 | + | firstFrame := commandFrame(ports.CommandRequest{ | |
| 405 | + | Slug: "split-right", Self: true, TargetSession: "work", | |
| 406 | + | TargetTab: "t_invoking", TargetPane: "p_invoking", | |
| 407 | + | }) | |
| 333 | 408 | firstTransport, firstSends, _ := newConn(t, firstFrame) | |
| 334 | 409 | firstDone := make(chan struct{}) | |
| 335 | 410 | go func() { | |
| @@ -342,7 +417,10 @@ func TestHandleCommandSerializesResolutionAndExecutionPerSession(t *testing.T) { | |||
| 342 | 417 | t.Fatal("first command did not enter its blocked action") | |
| 343 | 418 | } | |
| 344 | 419 | ||
| 345 | - | secondFrame := commandFrame(ports.CommandRequest{Slug: "focus-pane-left", TargetSession: "work"}) | |
| 420 | + | secondFrame := commandFrame(ports.CommandRequest{ | |
| 421 | + | Slug: "focus-pane-right", Self: true, TargetSession: "work", | |
| 422 | + | TargetTab: "t_invoking", TargetPane: "p_invoking", | |
| 423 | + | }) | |
| 346 | 424 | secondTransport, secondSends, _ := newConn(t, secondFrame) | |
| 347 | 425 | secondDone := make(chan struct{}) | |
| 348 | 426 | go func() { | |
| @@ -360,11 +438,13 @@ func TestHandleCommandSerializesResolutionAndExecutionPerSession(t *testing.T) { | |||
| 360 | 438 | require.True(t, awaitCommandResult(t, firstSends).OK) | |
| 361 | 439 | require.True(t, awaitCommandResult(t, secondSends).OK) | |
| 362 | 440 | ||
| 363 | - | tb := sess.activeTab() | |
| 364 | - | tb.mu.Lock() | |
| 365 | - | defer tb.mu.Unlock() | |
| 366 | - | require.Len(t, tb.panes, 2) | |
| 367 | - | require.Equal(t, original, tb.tree.Focus, "second command must resolve after the split changed focus") | |
| 441 | + | sess.mu.Lock() | |
| 442 | + | require.Zero(t, sess.active, "self commands must not select the invoking tab") | |
| 443 | + | sess.mu.Unlock() | |
| 444 | + | target.mu.Lock() | |
| 445 | + | defer target.mu.Unlock() | |
| 446 | + | require.Len(t, target.panes, 2) | |
| 447 | + | require.NotEqual(t, original, target.tree.Focus, "second command must execute after the split creates its right neighbor") | |
| 368 | 448 | } | |
| 369 | 449 | ||
| 370 | 450 | func commandFrame(request ports.CommandRequest) ports.Frame { | |
| @@ -97,6 +97,15 @@ func TestHandleCommandDispatchAndTargetErrors(t *testing.T) { | |||||
| 97 | addControlSession(d, "one", "t_one", "p_one") | 97 | addControlSession(d, "one", "t_one", "p_one") | ||
| 98 | addControlSession(d, "two", "t_two", "p_two") | 98 | addControlSession(d, "two", "t_two", "p_two") | ||
| 99 | }, request: ports.CommandRequest{Slug: "split-right", TargetSession: "one", TargetTab: "t_two", TargetPane: "p_two"}, code: ports.ErrNoSuchTarget}, | 99 | }, request: ports.CommandRequest{Slug: "split-right", TargetSession: "one", TargetTab: "t_two", TargetPane: "p_two"}, code: ports.ErrNoSuchTarget}, | ||
| 100 | + | {name: "self requires both IDs", arrange: func(d *Daemon) { | |||
| 101 | + | addControlSession(d, "work", "t_work", "p_work") | |||
| 102 | + | }, request: ports.CommandRequest{Slug: "split-right", Self: true, TargetSession: "work", TargetTab: "t_work"}, code: ports.ErrNoSuchTarget}, | |||
| 103 | + | {name: "self rejects pane from another tab", arrange: func(d *Daemon) { | |||
| 104 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |||
| 105 | + | other := newTabWithStableID("t_other", "p_other", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |||
| 106 | + | other.ctx, other.cancel = context.WithCancel(d.serveCtx) | |||
| 107 | + | sess.tabs = append(sess.tabs, other) | |||
| 108 | + | }, request: ports.CommandRequest{Slug: "split-right", Self: true, TargetSession: "work", TargetTab: "t_work", TargetPane: "p_other"}, code: ports.ErrNoSuchTarget}, | |||
| 100 | } | 109 | } | ||
| 101 | 110 | ||||
| 102 | for _, tt := range tests { | 111 | for _, tt := range tests { | ||
| @@ -192,6 +201,63 @@ func TestHandleCommandStableIDsDoNotRedirectSplitFromCurrentFocus(t *testing.T) | |||||
| 192 | require.Equal(t, 1, secondPaneCount, "stable IDs are only a session locator") | 201 | require.Equal(t, 1, secondPaneCount, "stable IDs are only a session locator") | ||
| 193 | } | 202 | } | ||
| 194 | 203 | ||||
| 204 | + | func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) { | |||
| 205 | + | factory := &controlPTYFactory{} | |||
| 206 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 207 | + | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |||
| 208 | + | sess := addControlSession(d, "work", "t_active", "p_active") | |||
| 209 | + | active := sess.tabs[0] | |||
| 210 | + | invoking := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |||
| 211 | + | invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx) | |||
| 212 | + | sess.mu.Lock() | |||
| 213 | + | sess.tabs = append(sess.tabs, invoking) | |||
| 214 | + | sess.active = 0 | |||
| 215 | + | sess.mu.Unlock() | |||
| 216 | + | ||||
| 217 | + | rename := sendCommand(t, d, ports.CommandRequest{ | |||
| 218 | + | Slug: "rename-tab", Args: []string{"invoking"}, Self: true, | |||
| 219 | + | TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking", | |||
| 220 | + | }) | |||
| 221 | + | require.True(t, rename.OK, rename.Text) | |||
| 222 | + | active.mu.Lock() | |||
| 223 | + | require.Empty(t, active.name) | |||
| 224 | + | active.mu.Unlock() | |||
| 225 | + | invoking.mu.Lock() | |||
| 226 | + | require.Equal(t, "invoking", invoking.name) | |||
| 227 | + | invoking.mu.Unlock() | |||
| 228 | + | ||||
| 229 | + | split := sendCommand(t, d, ports.CommandRequest{ | |||
| 230 | + | Slug: "split-right", Self: true, | |||
| 231 | + | TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking", | |||
| 232 | + | }) | |||
| 233 | + | require.True(t, split.OK, split.Text) | |||
| 234 | + | active.mu.Lock() | |||
| 235 | + | require.Len(t, active.panes, 1) | |||
| 236 | + | active.mu.Unlock() | |||
| 237 | + | invoking.mu.Lock() | |||
| 238 | + | require.Len(t, invoking.panes, 2) | |||
| 239 | + | invoking.mu.Unlock() | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | func TestHandleCommandSelfListPanesUsesInvokingTab(t *testing.T) { | |||
| 243 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 244 | + | sess := addControlSession(d, "work", "t_active", "p_active") | |||
| 245 | + | invoking := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |||
| 246 | + | invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx) | |||
| 247 | + | sess.mu.Lock() | |||
| 248 | + | sess.tabs = append(sess.tabs, invoking) | |||
| 249 | + | sess.active = 0 | |||
| 250 | + | sess.mu.Unlock() | |||
| 251 | + | ||||
| 252 | + | result := sendCommand(t, d, ports.CommandRequest{ | |||
| 253 | + | Slug: "list-panes", Self: true, JSON: true, | |||
| 254 | + | TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking", | |||
| 255 | + | }) | |||
| 256 | + | require.True(t, result.OK, result.Text) | |||
| 257 | + | require.Contains(t, result.Output, "p_invoking") | |||
| 258 | + | require.NotContains(t, result.Output, "p_active") | |||
| 259 | + | } | |||
| 260 | + | ||||
| 195 | func TestHandleCommandRenameSessionRejectsInvalidNameAsCommandArgs(t *testing.T) { | 261 | func TestHandleCommandRenameSessionRejectsInvalidNameAsCommandArgs(t *testing.T) { | ||
| 196 | d := newTestDaemon(t, nil, stubClock{}) | 262 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 197 | addControlSession(d, "work", "t_work", "p_work") | 263 | addControlSession(d, "work", "t_work", "p_work") | ||
| @@ -322,14 +388,23 @@ func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) { | |||||
| 322 | require.Equal(t, true, decoded[0]["focused"]) | 388 | require.Equal(t, true, decoded[0]["focused"]) | ||
| 323 | } | 389 | } | ||
| 324 | 390 | ||||
| 325 | - | func TestHandleCommandSerializesResolutionAndExecutionPerSession(t *testing.T) { | 391 | + | func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) { |
| 326 | factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})} | 392 | factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})} | ||
| 327 | d := newTestDaemon(t, factory, stubClock{}) | 393 | d := newTestDaemon(t, factory, stubClock{}) | ||
| 328 | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | 394 | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | ||
| 329 | - | sess := addControlSession(d, "work", "t_work", "p_work") | 395 | + | sess := addControlSession(d, "work", "t_active", "p_active") |
| 330 | - | original := sess.tabs[0].tree.Focus | 396 | + | target := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) |
| 397 | + | target.ctx, target.cancel = context.WithCancel(d.serveCtx) | |||
| 398 | + | sess.mu.Lock() | |||
| 399 | + | sess.tabs = append(sess.tabs, target) | |||
| 400 | + | sess.active = 0 | |||
| 401 | + | sess.mu.Unlock() | |||
| 402 | + | original := target.tree.Focus | |||
| 331 | 403 | ||||
| 332 | - | firstFrame := commandFrame(ports.CommandRequest{Slug: "split-right", TargetSession: "work"}) | 404 | + | firstFrame := commandFrame(ports.CommandRequest{ |
| 405 | + | Slug: "split-right", Self: true, TargetSession: "work", | |||
| 406 | + | TargetTab: "t_invoking", TargetPane: "p_invoking", | |||
| 407 | + | }) | |||
| 333 | firstTransport, firstSends, _ := newConn(t, firstFrame) | 408 | firstTransport, firstSends, _ := newConn(t, firstFrame) | ||
| 334 | firstDone := make(chan struct{}) | 409 | firstDone := make(chan struct{}) | ||
| 335 | go func() { | 410 | go func() { | ||
| @@ -342,7 +417,10 @@ func TestHandleCommandSerializesResolutionAndExecutionPerSession(t *testing.T) { | |||||
| 342 | t.Fatal("first command did not enter its blocked action") | 417 | t.Fatal("first command did not enter its blocked action") | ||
| 343 | } | 418 | } | ||
| 344 | 419 | ||||
| 345 | - | secondFrame := commandFrame(ports.CommandRequest{Slug: "focus-pane-left", TargetSession: "work"}) | 420 | + | secondFrame := commandFrame(ports.CommandRequest{ |
| 421 | + | Slug: "focus-pane-right", Self: true, TargetSession: "work", | |||
| 422 | + | TargetTab: "t_invoking", TargetPane: "p_invoking", | |||
| 423 | + | }) | |||
| 346 | secondTransport, secondSends, _ := newConn(t, secondFrame) | 424 | secondTransport, secondSends, _ := newConn(t, secondFrame) | ||
| 347 | secondDone := make(chan struct{}) | 425 | secondDone := make(chan struct{}) | ||
| 348 | go func() { | 426 | go func() { | ||
| @@ -360,11 +438,13 @@ func TestHandleCommandSerializesResolutionAndExecutionPerSession(t *testing.T) { | |||||
| 360 | require.True(t, awaitCommandResult(t, firstSends).OK) | 438 | require.True(t, awaitCommandResult(t, firstSends).OK) | ||
| 361 | require.True(t, awaitCommandResult(t, secondSends).OK) | 439 | require.True(t, awaitCommandResult(t, secondSends).OK) | ||
| 362 | 440 | ||||
| 363 | - | tb := sess.activeTab() | 441 | + | sess.mu.Lock() |
| 364 | - | tb.mu.Lock() | 442 | + | require.Zero(t, sess.active, "self commands must not select the invoking tab") |
| 365 | - | defer tb.mu.Unlock() | 443 | + | sess.mu.Unlock() |
| 366 | - | require.Len(t, tb.panes, 2) | 444 | + | target.mu.Lock() |
| 367 | - | require.Equal(t, original, tb.tree.Focus, "second command must resolve after the split changed focus") | 445 | + | defer target.mu.Unlock() |
| 446 | + | require.Len(t, target.panes, 2) | |||
| 447 | + | require.NotEqual(t, original, target.tree.Focus, "second command must execute after the split creates its right neighbor") | |||
| 368 | } | 448 | } | ||
| 369 | 449 | ||||
| 370 | func commandFrame(request ports.CommandRequest) ports.Frame { | 450 | func commandFrame(request ports.CommandRequest) ports.Frame { | ||