detached b2b8267

fix(control): honor explicit self target

commit
b2b8267511973963f1891e99379e9f7ce13e58d5
author
brice · · 2026-07-24 05:16 UTC
parent
1b18dd9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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)
139139 }
140140 request := ports.CommandRequest{
141141 Version: ports.ProtocolVersion,
142+ Self: invocation.self,
142143 Slug: invocation.slug,
143144 Args: invocation.args,
144145 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) {
117117 if err != nil {
118118 t.Fatalf("decode request: %v", err)
119119 }
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" {
121121 t.Fatalf("request = type %d %+v", transport.sent[0].Type, req)
122122 }
123123 if out.String() != "done\n" {
@@ -125,6 +125,25 @@ func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) {
125125 }
126126 }
127127
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+
128147 func TestRunCmdExplicitSessionDoesNotUseEnvIDsWithoutSelf(t *testing.T) {
129148 transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}}
130149 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 {
194194 // stay first so a future payload layout can still be rejected cleanly.
195195 type CommandRequest struct {
196196 Version uint16
197+ Self bool
197198 Slug string
198199 Args []string
199200 TargetSession string
@@ -889,6 +890,11 @@ func UnmarshalKill(b []byte) (Kill, error) {
889890 func MarshalCommandRequest(m CommandRequest) []byte {
890891 w := payloadWriter{}
891892 w.putUint16(m.Version)
893+ if m.Self {
894+ w.putUint8(1)
895+ } else {
896+ w.putUint8(0)
897+ }
892898 w.putString(m.Slug)
893899 w.putUint16(uint16(len(m.Args)))
894900 for _, arg := range m.Args {
@@ -914,6 +920,11 @@ func UnmarshalCommandRequest(b []byte) (CommandRequest, error) {
914920 if m.Version, err = r.getUint16(); err != nil {
915921 return CommandRequest{}, err
916922 }
923+ selfFlag, err := r.getUint8()
924+ if err != nil {
925+ return CommandRequest{}, err
926+ }
927+ m.Self = selfFlag != 0
917928 if m.Slug, err = r.getString(); err != nil {
918929 return CommandRequest{}, err
919930 }
@@ -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 uint16196 Version uint16
197+ Self bool
197 Slug string198 Slug string
198 Args []string199 Args []string
199 TargetSession string200 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{}, err921 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{}, err929 return CommandRequest{}, err
919 }930 }
M internal/ports/wire_test.go +4 -1
@@ -461,6 +461,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
461461 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
462462 want: []byte{
463463 0x00, 0x12,
464+ 0x00,
464465 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
465466 0x00, 0x00,
466467 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -471,6 +472,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
471472 name: "full",
472473 msg: CommandRequest{
473474 Version: ProtocolVersion,
475+ Self: true,
474476 Slug: "toast",
475477 Args: []string{"-l", "warn", "tests KO"},
476478 TargetSession: "dev",
@@ -480,6 +482,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
480482 },
481483 want: []byte{
482484 0x00, 0x12,
485+ 0x01,
483486 0x00, 0x05, 't', 'o', 'a', 's', 't',
484487 0x00, 0x03,
485488 0x00, 0x00, 0x00, 0x02, '-', 'l',
@@ -515,7 +518,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
515518
516519 func TestCommandRequestRejectsImpossibleArgumentCount(t *testing.T) {
517520 payload := MarshalCommandRequest(CommandRequest{Version: ProtocolVersion, Slug: "toast"})
518- argCountOffset := 2 + 2 + len("toast")
521+ argCountOffset := 2 + 1 + 2 + len("toast")
519522 payload[argCountOffset] = 0xff
520523 payload[argCountOffset+1] = 0xff
521524
@@ -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] = 0xff522 payload[argCountOffset] = 0xff
520 payload[argCountOffset+1] = 0xff523 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
4242 if !cmd.Scriptable || cmd.Control == nil {
4343 return commandFailure(ports.ErrNotScriptable, request.Slug+" requires an attached client")
4444 }
45- if cmd.Target == command.TargetNone {
45+ if cmd.Target == command.TargetNone && !request.Self {
4646 return d.runControl(cmd, controlExec{d: d}, request)
4747 }
4848
@@ -53,7 +53,7 @@ func (d *Daemon) dispatchCommand(request ports.CommandRequest) ports.CommandResu
5353 sess.dispatchMu.Lock()
5454 defer sess.dispatchMu.Unlock()
5555
56- tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request.TargetTab, request.TargetPane)
56+ tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request)
5757 if code != 0 {
5858 return commandFailure(code, text)
5959 }
@@ -87,6 +87,9 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u
8787 defer d.mu.Unlock()
8888
8989 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+ }
9093 if request.TargetSession != "" && request.TargetTab == "" && request.TargetPane == "" {
9194 if named == nil {
9295 return nil, ports.ErrNoSuchTarget, "no such session: " + request.TargetSession
@@ -135,11 +138,27 @@ func (s *session) containsStableIDs(tabID, paneID string) bool {
135138 return false
136139 }
137140
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) {
139142 sess.mu.Lock()
140143 tabs := append([]*tab(nil), sess.tabs...)
141144 active := sess.active
142145 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+ }
143162 if len(tabs) == 0 || active < 0 || active >= len(tabs) {
144163 return nil, nil, ports.ErrNoSuchTarget, "target session has no active tab"
145164 }
@@ -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.TargetSession95 return nil, ports.ErrNoSuchTarget, "no such session: " + request.TargetSession
@@ -135,11 +138,27 @@ func (s *session) containsStableIDs(tabID, paneID string) bool {
135 return false138 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.active144 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) {
9797 addControlSession(d, "one", "t_one", "p_one")
9898 addControlSession(d, "two", "t_two", "p_two")
9999 }, 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},
100109 }
101110
102111 for _, tt := range tests {
@@ -192,6 +201,63 @@ func TestHandleCommandStableIDsDoNotRedirectSplitFromCurrentFocus(t *testing.T)
192201 require.Equal(t, 1, secondPaneCount, "stable IDs are only a session locator")
193202 }
194203
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+
195261 func TestHandleCommandRenameSessionRejectsInvalidNameAsCommandArgs(t *testing.T) {
196262 d := newTestDaemon(t, nil, stubClock{})
197263 addControlSession(d, "work", "t_work", "p_work")
@@ -322,14 +388,23 @@ func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) {
322388 require.Equal(t, true, decoded[0]["focused"])
323389 }
324390
325-func TestHandleCommandSerializesResolutionAndExecutionPerSession(t *testing.T) {
391+func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
326392 factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})}
327393 d := newTestDaemon(t, factory, stubClock{})
328394 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
331403
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+ })
333408 firstTransport, firstSends, _ := newConn(t, firstFrame)
334409 firstDone := make(chan struct{})
335410 go func() {
@@ -342,7 +417,10 @@ func TestHandleCommandSerializesResolutionAndExecutionPerSession(t *testing.T) {
342417 t.Fatal("first command did not enter its blocked action")
343418 }
344419
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+ })
346424 secondTransport, secondSends, _ := newConn(t, secondFrame)
347425 secondDone := make(chan struct{})
348426 go func() {
@@ -360,11 +438,13 @@ func TestHandleCommandSerializesResolutionAndExecutionPerSession(t *testing.T) {
360438 require.True(t, awaitCommandResult(t, firstSends).OK)
361439 require.True(t, awaitCommandResult(t, secondSends).OK)
362440
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")
368448 }
369449
370450 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.Focus396+ 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 {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close